diff --git a/.github/media/analytics.png b/.github/media/analytics.png new file mode 100644 index 0000000..4c479ab Binary files /dev/null and b/.github/media/analytics.png differ diff --git a/README.md b/README.md index 2301eed..3ff9a43 100644 --- a/README.md +++ b/README.md @@ -216,12 +216,19 @@ register a user, or write user meta — each with a capability ceiling that a fo author cannot raise. **Managing** — an entries table with search, filters and star; a detail view; -bulk read/spam/trash; CSV export with formula injection defused; per-form -analytics (views, starts, submissions, conversion, per-field drop-off); survey -and quiz reporting. +bulk read/spam/trash; CSV export with formula injection defused; survey and quiz +reporting. + +**Reporting** — an analytics window with views, starts, conversion and completion; +submissions per day; a tally or a distribution per question; Net Promoter Score +for any 0–10 question; and a cross-tab that breaks every numeric answer down by +any categorical one — which is the difference between "the average score is 7.5" +and "Support scores 5.8 and everyone else is above 7". ![The entries window: submissions on the left, one submission's answers on the right](.github/media/entries.png) +![The analytics window: headline counts, submissions per day, a Net Promoter Score, and every answer broken down by team](.github/media/analytics.png) + **Privacy** — a retention policy that deletes automatically, IP anonymisation, and integration with WordPress's own export and erase tools so a subject access request reaches form submissions without anybody having to remember they are a diff --git a/allterrain-forms.php b/allterrain-forms.php index 6f78c08..1700e16 100644 --- a/allterrain-forms.php +++ b/allterrain-forms.php @@ -151,6 +151,8 @@ require_once ATF_DIR . 'includes/actions.php'; require_once ATF_DIR . 'includes/entries.php'; require_once ATF_DIR . 'includes/analytics.php'; +require_once ATF_DIR . 'includes/dev-mode.php'; +require_once ATF_DIR . 'includes/demo-data.php'; require_once ATF_DIR . 'includes/templates.php'; require_once ATF_DIR . 'includes/rest.php'; require_once ATF_DIR . 'includes/preview.php'; diff --git a/assets/css/analytics.css b/assets/css/analytics.css new file mode 100644 index 0000000..559e2f0 --- /dev/null +++ b/assets/css/analytics.css @@ -0,0 +1,461 @@ +/** + * The analytics window. + * + * Depends on `builder.css`, which is where the `--atfb-*` tokens are defined + * against the shell's own palette. Nothing here invents a colour: a report that + * looked right in the light theme and unreadable in the dark one would be a + * report nobody trusts, and the shell already answers that question. + * + * The charts are ordinary elements. A bar is a `div` whose width is a + * percentage, a histogram column is a `div` whose height is one — so resizing + * the window is a reflow rather than a redraw, and every label in every chart is + * real text that can be read aloud, selected and searched. + */ + +.atfa { + display: flex; + flex-direction: column; + block-size: 100%; + background: var( --atfb-bg ); + color: var( --atfb-fg ); + font-size: 13px; +} + +.atfa__bar { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-block-end: 1px solid var( --atfb-border ); + background: var( --atfb-surface ); + flex: 0 0 auto; +} + +.atfa__bar-label { + color: var( --atfb-muted ); + font-size: 12px; +} + +.atfa__body { + flex: 1 1 auto; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.atfa__empty { + margin: 0; + padding: 32px 16px; + color: var( --atfb-muted ); + text-align: center; +} + +/* --- Headline numbers ----------------------------------------------------- */ + +.atfa-kpis { + display: grid; + /* Wraps by itself. Six fixed columns would be six unreadable slivers in a + window somebody has dragged narrow, which is a thing people do to put a + report beside the form it is about. */ + grid-template-columns: repeat( auto-fit, minmax( 130px, 1fr ) ); + gap: 10px; +} + +.atfa-kpi { + display: flex; + flex-direction: column; + gap: 2px; + padding: 12px 14px; + border: 1px solid var( --atfb-border ); + border-radius: 8px; + background: var( --atfb-surface ); +} + +.atfa-kpi__value { + font-size: 24px; + font-weight: 600; + line-height: 1.1; + /* Tabular figures, so a number ticking up during a demo-data run does not + shuffle the label underneath it. */ + font-variant-numeric: tabular-nums; +} + +.atfa-kpi__label { + color: var( --atfb-muted ); + font-size: 12px; +} + +.atfa-kpi__hint { + color: var( --atfb-faint ); + font-size: 11px; +} + +/* --- Panels --------------------------------------------------------------- */ + +.atfa-panel { + padding: 14px 16px 16px; + border: 1px solid var( --atfb-border ); + border-radius: 8px; + background: var( --atfb-surface ); +} + +.atfa-panel__head { + margin-block-end: 12px; +} + +.atfa-panel__title { + margin: 0; + font-size: 13px; + font-weight: 600; +} + +.atfa-panel__note { + margin: 2px 0 0; + color: var( --atfb-muted ); + font-size: 12px; +} + +/* --- Timeline ------------------------------------------------------------- */ + +.atfa-timeline { + display: flex; + align-items: flex-end; + gap: 1px; + block-size: 120px; + padding-block-end: 2px; + border-block-end: 1px solid var( --atfb-border ); +} + +.atfa-timeline__day { + flex: 1 1 0; + min-inline-size: 2px; + background: var( --atfb-accent ); + border-radius: 2px 2px 0 0; + transition: background-color 120ms ease; +} + +.atfa-timeline__day:hover { + background: var( --atfb-accent-strong ); +} + +/* A day with no submissions still occupies its column. Collapsing it would let + a quiet fortnight close up, and a chart that hides the gaps shows a steady + trickle where the truth was one spike and three weeks of silence. */ +.atfa-timeline__day.is-empty { + background: var( --atfb-border ); + block-size: 2px; +} + +.atfa-timeline__axis { + display: flex; + justify-content: space-between; + margin-block-start: 4px; + color: var( --atfb-faint ); + font-size: 11px; +} + +/* --- Net Promoter Score --------------------------------------------------- */ + +.atfa-nps { + display: flex; + align-items: center; + gap: 20px; + flex-wrap: wrap; +} + +.atfa-nps__score { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-inline-size: 104px; + padding: 12px 16px; + border-radius: 8px; + background: var( --atfb-well ); +} + +.atfa-nps__number { + font-size: 34px; + font-weight: 700; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.atfa-nps__caption { + margin-block-start: 2px; + color: var( --atfb-muted ); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +/* The three verdicts. Not a red-to-green ramp: the difference between -5 and +5 + is a rounding error in a survey of five hundred people, and colouring them as + opposites would invent a finding. */ +.atfa-nps__score.is-poor .atfa-nps__number { + color: #b32d2e; +} + +.atfa-nps__score.is-ok .atfa-nps__number { + color: var( --atfb-fg ); +} + +.atfa-nps__score.is-great .atfa-nps__number { + color: #007017; +} + +.atfa-nps__detail { + flex: 1 1 260px; +} + +.atfa-nps__bar { + display: flex; + block-size: 22px; + border-radius: 4px; + overflow: hidden; + background: var( --atfb-well ); +} + +.atfa-nps__band.is-detractors { + background: #d63638; +} + +.atfa-nps__band.is-passives { + background: var( --atfb-border-strong ); +} + +.atfa-nps__band.is-promoters { + background: #008a20; +} + +.atfa-nps__legend { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin-block-start: 8px; + font-size: 12px; +} + +.atfa-nps__key { + display: inline-flex; + align-items: baseline; + gap: 4px; +} + +.atfa-nps__key::before { + content: ""; + inline-size: 9px; + block-size: 9px; + border-radius: 2px; + align-self: center; +} + +.atfa-nps__key.is-detractors::before { + background: #d63638; +} + +.atfa-nps__key.is-passives::before { + background: var( --atfb-border-strong ); +} + +.atfa-nps__key.is-promoters::before { + background: #008a20; +} + +.atfa-nps__key small { + color: var( --atfb-faint ); +} + +/* --- Bars, shared by the tallies and the cross-tab ------------------------ */ + +.atfa-meter { + block-size: 8px; + border-radius: 999px; + background: var( --atfb-well ); + overflow: hidden; +} + +.atfa-meter__fill { + block-size: 100%; + background: var( --atfb-accent ); + border-radius: inherit; +} + +.atfa-meter__value { + display: inline-block; + margin-block-start: 3px; + color: var( --atfb-muted ); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.atfa-bars { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; +} + +.atfa-bars__row { + display: grid; + /* The label column is fixed so every bar in a question starts at the same + place — ragged bar starts make two options impossible to compare, which is + the only thing a tally is for. */ + grid-template-columns: minmax( 90px, 200px ) 1fr auto; + align-items: center; + gap: 10px; +} + +.atfa-bars__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.atfa-bars__value { + color: var( --atfb-muted ); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: pre; +} + +/* --- Distribution --------------------------------------------------------- */ + +.atfa-hist { + display: flex; + align-items: flex-end; + gap: 6px; + block-size: 150px; +} + +.atfa-hist__col { + flex: 1 1 0; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + block-size: 100%; + gap: 3px; +} + +.atfa-hist__bar { + inline-size: 100%; + background: var( --atfb-accent ); + border-radius: 3px 3px 0 0; + min-block-size: 2px; +} + +/* The bar the mean falls in. Marked because the interesting case is the one + where the mean lands somewhere almost nobody actually answered. */ +.atfa-hist__col.is-mean .atfa-hist__bar { + background: var( --atfb-accent-strong ); + outline: 2px solid var( --atfb-accent-line ); + outline-offset: 1px; +} + +.atfa-hist__count, +.atfa-hist__tick { + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var( --atfb-muted ); +} + +.atfa-hist__tick { + color: var( --atfb-faint ); +} + +/* --- The cross-tab -------------------------------------------------------- */ + +.atfa-table { + inline-size: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.atfa-table th, +.atfa-table td { + padding: 8px 10px; + text-align: start; + vertical-align: middle; + border-block-end: 1px solid var( --atfb-border ); +} + +.atfa-table thead th { + color: var( --atfb-muted ); + font-weight: 500; + white-space: nowrap; +} + +.atfa-table__label { + font-weight: 600; +} + +.atfa-table__count { + color: var( --atfb-muted ); + font-variant-numeric: tabular-nums; +} + +.atfa-table tbody tr:last-child th, +.atfa-table tbody tr:last-child td { + border-block-end: 0; +} + +.atfa-questions { + display: flex; + flex-direction: column; + gap: 16px; +} + +.atfa-question__note { + margin: 0; + color: var( --atfb-muted ); +} + +/* --- The developer panel -------------------------------------------------- */ + +/* Visibly not part of the report. It writes several hundred entries into + whatever database this happens to be, and it should never be mistaken for one + more panel of numbers. */ +.atfa-demo { + padding: 14px 16px 16px; + border: 1px dashed var( --atfb-border-strong ); + border-radius: 8px; + background: var( --atfb-well ); +} + +.atfa-demo__title { + margin: 0 0 4px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var( --atfb-muted ); +} + +.atfa-demo__note { + margin: 0 0 10px; + max-inline-size: 70ch; + color: var( --atfb-muted ); + font-size: 12px; +} + +.atfa-demo__status { + margin: 0 0 10px; + font-variant-numeric: tabular-nums; +} + +.atfa-demo__actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +@media ( prefers-reduced-motion: reduce ) { + .atfa-timeline__day { + transition: none; + } +} diff --git a/assets/css/builder.css b/assets/css/builder.css index a94441c..923ab2f 100644 --- a/assets/css/builder.css +++ b/assets/css/builder.css @@ -15,9 +15,14 @@ * @package AllTerrain_Forms */ +/* Every window root this plugin owns. A root missing from this list inherits no + tokens at all, which does not fail loudly: `var( --atfb-accent )` with nothing + behind it is simply an invalid declaration, so the element keeps its initial + value and a chart of coloured bars renders as a blank rectangle. */ .atfb, .atfe, -.atfs { +.atfs, +.atfa { /* The window's own surface. A window body must not paint a colour of its own choosing behind the shell's chrome. */ --atfb-bg: var( --os-window-bg, #fff ); diff --git a/assets/js/analytics.js b/assets/js/analytics.js new file mode 100644 index 0000000..199f1ab --- /dev/null +++ b/assets/js/analytics.js @@ -0,0 +1,840 @@ +var allTerrainFormsAnalytics = function(exports) { + "use strict"; + function getShell() { + const wp = window.wp; + return wp?.os ?? null; + } + const config = window.allTerrainForms; + class ApiError extends Error { + constructor(message, status, code = "") { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + } + } + async function request(path, init = {}) { + if (!config?.restUrl) { + throw new ApiError("AllTerrain Forms is not configured on this page.", 0); + } + const url = `${config.restUrl}${path}`; + const headers = { + "Content-Type": "application/json", + ...init.headers ?? {} + }; + if (config.nonce) { + headers["X-WP-Nonce"] = config.nonce; + } + const shell = getShell(); + const doFetch = shell?.fetch ? (input, options) => shell.fetch(input, options, { source: "allterrain-forms" }) : (input, options) => fetch(input, options); + const response = await doFetch(url, { + credentials: "same-origin", + ...init, + headers + }); + if (!response.ok) { + let message = `Request failed with status ${response.status}.`; + let code = ""; + try { + const body = await response.json(); + message = body.message ?? message; + code = body.code ?? ""; + } catch { + } + throw new ApiError(message, response.status, code); + } + if (response.status === 204) { + return void 0; + } + return await response.json(); + } + const get = (path) => request(path); + async function wpGet(route) { + if (!config?.wpRestUrl) { + throw new ApiError("AllTerrain Forms is not configured on this page.", 0); + } + const headers = config.nonce ? { "X-WP-Nonce": config.nonce } : {}; + const shell = getShell(); + const url = `${config.wpRestUrl}${route}`; + const response = shell?.fetch ? await shell.fetch(url, { credentials: "same-origin", headers }, { source: "allterrain-forms" }) : await fetch(url, { credentials: "same-origin", headers }); + if (!response.ok) { + throw new ApiError(`Request failed with status ${response.status}.`, response.status); + } + return await response.json(); + } + function decodeEntities(html) { + if (!html || !html.includes("&")) { + return html; + } + const textarea = document.createElement("textarea"); + textarea.innerHTML = html; + return textarea.value; + } + const post = (path, body) => request(path, { method: "POST", body: JSON.stringify(body) }); + const del = (path) => request(path, { method: "DELETE" }); + function query(params) { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === void 0 || value === "" || value === false) { + continue; + } + search.set(key, String(value)); + } + const string = search.toString(); + return string ? `?${string}` : ""; + } + const api = { + config: () => get("/config"), + listForms: () => get("/forms"), + getForm: (id) => get(`/forms/${id}`), + createForm: (body) => post("/forms", body), + updateForm: (id, body) => post(`/forms/${id}`, body), + duplicateForm: (id) => post(`/forms/${id}/duplicate`, {}), + deleteForm: (id) => del(`/forms/${id}`), + preview: (id, body) => post(`/forms/${id}/preview`, body), + /** + * The site's published pages, for the "send them to a page" confirmation. + * + * Core's own route rather than one of ours: `wp/v2/pages` already knows about + * capabilities, pagination and the page hierarchy, and a plugin re-exposing + * the same list is a second thing to keep in step with it. `_fields` keeps the + * payload to the two values the picker shows — a full page response carries + * rendered content, and a hundred of those is megabytes. + */ + pages: () => wpGet( + "wp/v2/pages?per_page=100&status=publish&orderby=title&order=asc&_fields=id,title" + ).then( + (pages) => pages.map((page) => ({ + id: page.id, + // The REST API returns titles HTML-encoded; `el()` sets text through + // `textContent`, so without decoding, a page called "Q&A" shows as + // "Q&A" in the picker. + title: decodeEntities(page.title?.rendered ?? "") || `#${page.id}` + })) + ), + mergeTags: (id) => get(`/forms/${id}/merge-tags`).then((response) => response.groups), + analytics: (id, dimension = "") => get( + `/forms/${id}/analytics${dimension ? `?dimension=${encodeURIComponent(dimension)}` : ""}` + ), + /** + * The demo-data tools. + * + * Every one of these 404s unless developer mode is on, which is why the + * window asks for the status before drawing the panel rather than drawing the + * panel and letting the buttons fail. + */ + demoStatus: () => get("/demo"), + /** Generates one chunk. Called until `remaining` reaches zero. */ + demoSeed: (count) => post("/demo", count ? { count } : {}), + demoRemove: () => del("/demo"), + listEntries: (params) => get(`/entries${query(params)}`), + getEntry: (id) => get(`/entries/${id}`), + updateEntry: (id, body) => post(`/entries/${id}`, body), + deleteEntry: (id) => del(`/entries/${id}`), + exportEntries: (params) => get(`/entries/export${query(params)}`), + listThemes: () => get("/themes"), + saveTheme: (body) => post("/themes", body), + deleteTheme: (id) => del(`/themes/${id}`) + }; + function el(tag, options = {}) { + const node = document.createElement(tag); + if (options.class) { + node.className = options.class; + } + if (options.text !== void 0) { + node.textContent = options.text; + } + if (options.html !== void 0) { + node.innerHTML = options.html; + } + if (options.title) { + node.title = options.title; + } + if (options.type && "type" in node) { + node.type = options.type; + } + if (options.value !== void 0 && "value" in node) { + node.value = options.value; + } + if (options.placeholder !== void 0 && "placeholder" in node) { + node.placeholder = options.placeholder; + } + if (options.href && "href" in node) { + node.href = options.href; + } + for (const [name, value] of Object.entries(options.attrs ?? {})) { + if (value === void 0 || value === false) { + continue; + } + node.setAttribute(name, value === true ? "" : String(value)); + } + Object.assign(node.style, options.style ?? {}); + for (const [event, handler] of Object.entries(options.on ?? {})) { + node.addEventListener(event, handler); + } + for (const child of options.children ?? []) { + if (child === null || child === void 0 || child === false) { + continue; + } + node.append(child); + } + return node; + } + function clear(node) { + node.replaceChildren(); + } + function select(value, options, onChange) { + if (hasComponent("os-select") && hasComponent("os-option")) { + const host = document.createElement("os-select"); + host.setAttribute("value", value); + host.classList.add("atfb-field"); + for (const option of options) { + const item = document.createElement("os-option"); + item.setAttribute("value", option.value); + item.textContent = option.label; + host.append(item); + } + host.addEventListener("os-pick", (event) => { + onChange(String(event.detail?.value ?? "")); + }); + return host; + } + return el("select", { + class: "atfb-input atfb-select", + on: { + change: (event) => onChange(event.target.value) + }, + children: options.map( + (option) => el("option", { + value: option.value, + text: option.label, + attrs: { selected: option.value === value } + }) + ) + }); + } + function button(label, onClick, variant = "secondary", iconSlug) { + const children = [null, el("span", { text: label })]; + if (hasComponent("os-button")) { + const host = document.createElement("os-button"); + host.setAttribute("variant", variant); + host.setAttribute("type", "button"); + host.classList.add("atfb-button", `atfb-button--${variant}`); + host.addEventListener("click", onClick); + for (const child of children) { + if (child) { + host.append(child); + } + } + return host; + } + return el("button", { + class: `atfb-button atfb-button--${variant}`, + type: "button", + on: { click: onClick }, + children + }); + } + function hasComponent(tag) { + return typeof customElements !== "undefined" && Boolean(customElements.get(tag)); + } + const COMPONENTS = [ + "os-button", + "os-checkbox-label", + "os-number-field", + "os-select", + "os-option", + "os-segmented", + "os-segment", + "os-color-field", + "os-empty-state" + ]; + let componentsPending = null; + function whenComponents() { + if (componentsPending) { + return componentsPending; + } + const shell = window.wp?.os; + componentsPending = shell?.loadComponents ? shell.loadComponents(COMPONENTS).catch(() => void 0) : Promise.resolve(); + return componentsPending; + } + async function confirmAction(message, title = "") { + const shell = window.wp?.os; + if (shell?.confirm) { + return shell.confirm({ title, message, danger: true }); + } + return window.confirm(message); + } + function notify(title, body = "", type = "info") { + const shell = window.wp?.os; + if (shell?.notify) { + shell.notify({ title, body, type }); + return; + } + console.info(`[AllTerrain Forms] ${title}${body ? `: ${body}` : ""}`); + } + const NPS_BANDS = [ + { key: "detractors", label: "Detractors", hint: "0–6" }, + { key: "passives", label: "Passives", hint: "7–8" }, + { key: "promoters", label: "Promoters", hint: "9–10" } + ]; + class AnalyticsWindow { + constructor(root) { + this.forms = []; + this.formId = 0; + this.dimension = ""; + this.report = null; + this.demo = null; + this.demoAvailable = false; + this.bar = root.querySelector("[data-atfa-bar]") ?? el("div"); + this.body = root.querySelector("[data-atfa-body]") ?? el("div"); + } + /** Loads the form list and draws the first report. */ + async start() { + await whenComponents(); + try { + this.forms = await api.listForms(); + } catch { + this.fail("Could not load your forms."); + return; + } + if (!this.forms.length) { + this.fail("There are no forms yet. Build one, collect a few submissions, and this fills in."); + return; + } + if (config?.devMode && config?.canEdit) { + try { + this.demo = await api.demoStatus(); + this.demoAvailable = true; + } catch { + this.demoAvailable = false; + } + } + this.formId = this.forms[0].id; + await this.load(); + } + /** Fetches the report for the current form and redraws. */ + async load() { + try { + this.report = await api.analytics(this.formId, this.dimension); + } catch { + this.fail("Could not load that report."); + return; + } + this.dimension = this.report.breakdown?.id ?? ""; + this.renderBar(); + this.render(); + } + /** Says why there is nothing to show. */ + fail(message) { + clear(this.bar); + clear(this.body); + this.body.append(el("p", { class: "atfa__empty", text: message })); + } + /** The form picker, the grouping picker, and the headline counts. */ + renderBar() { + clear(this.bar); + this.bar.append( + select( + String(this.formId), + this.forms.map((form) => ({ value: String(form.id), label: form.title || `#${form.id}` })), + (value) => { + this.formId = Number(value); + this.dimension = ""; + void this.load(); + } + ) + ); + const dimensions = this.report?.dimensions ?? []; + if (dimensions.length > 1) { + this.bar.append( + el("span", { class: "atfa__bar-label", text: "Group by" }), + select( + this.dimension, + dimensions.map((item) => ({ value: item.id, label: item.label })), + (value) => { + this.dimension = value; + void this.load(); + } + ) + ); + } + } + /** Draws the whole report. */ + render() { + const report = this.report; + clear(this.body); + if (!report) { + return; + } + this.body.append(this.kpis(report)); + if (report.submissions < 1 && report.sampled < 1) { + this.body.append( + el("p", { + class: "atfa__empty", + text: "No submissions yet. Everything below fills in as they arrive." + }) + ); + } else { + this.body.append(this.timeline(report)); + for (const field of report.fields) { + if (field.nps) { + this.body.append(this.npsPanel(field)); + } + } + if (report.breakdown && report.breakdown.groups.length) { + this.body.append(this.breakdownPanel(report.breakdown)); + } + this.body.append(this.questions(report)); + } + if (this.demoAvailable) { + this.body.append(this.demoPanel()); + } + } + /** The headline numbers. */ + kpis(report) { + const cards = [ + { label: "Submissions", value: String(report.submissions) }, + { label: "Views", value: String(report.views) }, + { label: "Conversion", value: `${report.conversion}%`, hint: "of people who saw it" }, + { label: "Completion", value: `${report.completion}%`, hint: "of people who started" }, + { label: "Unread", value: String(report.unread) }, + { label: "Spam", value: String(report.spam) } + ]; + return el("div", { + class: "atfa-kpis", + children: cards.map( + (card) => el("div", { + class: "atfa-kpi", + children: [ + el("span", { class: "atfa-kpi__value", text: card.value }), + el("span", { class: "atfa-kpi__label", text: card.label }), + card.hint ? el("span", { class: "atfa-kpi__hint", text: card.hint }) : null + ] + }) + ) + }); + } + /** + * Submissions per day. + * + * Drawn as one element per day rather than an SVG path, so a day can carry its + * own tooltip and the whole thing stays readable when the window is narrow. + * The server sends every day in the window including the empty ones, which is + * what keeps a quiet fortnight looking quiet instead of closing up. + */ + timeline(report) { + const days = report.timeline; + const peak = days.reduce((most, day) => Math.max(most, day.count), 0); + const total = days.reduce((sum, day) => sum + day.count, 0); + return this.panel( + "When they answered", + `${total} in the last ${days.length} days · busiest day ${peak}`, + [ + el("div", { + class: "atfa-timeline", + children: days.map( + (day) => el("div", { + class: `atfa-timeline__day${day.count ? "" : " is-empty"}`, + attrs: { + // A percentage rather than a pixel height, so the chart + // scales with the window instead of being redrawn on + // every resize. + style: `height:${peak ? Math.max(2, day.count / peak * 100) : 0}%`, + title: `${day.date}: ${day.count}` + } + }) + ) + }), + el("div", { + class: "atfa-timeline__axis", + children: [ + el("span", { text: days[0]?.date ?? "" }), + el("span", { text: days[days.length - 1]?.date ?? "" }) + ] + }) + ] + ); + } + /** + * The NPS panel. + * + * The three bands are drawn to scale as one bar, which is the only honest way + * to show a score that is a *difference* between two of them: the passives in + * the middle contribute nothing to the number, and a chart that omits them + * makes the score look like a share of something. + */ + npsPanel(field) { + const nps = field.nps; + if (!nps) { + return el("div"); + } + const share = (count) => nps.responses ? count / nps.responses * 100 : 0; + return this.panel(field.label, `${nps.responses} answers · Net Promoter Score`, [ + el("div", { + class: "atfa-nps", + children: [ + el("div", { + class: `atfa-nps__score is-${nps.score >= 50 ? "great" : nps.score >= 0 ? "ok" : "poor"}`, + children: [ + el("span", { + class: "atfa-nps__number", + // Signed, always. An NPS of 6 and an NPS of -6 are very + // different results and differ by one character. + text: `${nps.score > 0 ? "+" : ""}${nps.score}` + }), + el("span", { class: "atfa-nps__caption", text: "NPS" }) + ] + }), + el("div", { + class: "atfa-nps__detail", + children: [ + el("div", { + class: "atfa-nps__bar", + children: NPS_BANDS.map( + (band) => el("div", { + class: `atfa-nps__band is-${band.key}`, + attrs: { + style: `width:${share(nps[band.key])}%`, + title: `${band.label} (${band.hint}): ${nps[band.key]}` + } + }) + ) + }), + el("div", { + class: "atfa-nps__legend", + children: NPS_BANDS.map( + (band) => el("span", { + class: `atfa-nps__key is-${band.key}`, + children: [ + el("strong", { text: String(nps[band.key]) }), + el("span", { text: ` ${band.label} ` }), + el("small", { text: band.hint }) + ] + }) + ) + }) + ] + }) + ] + }) + ]); + } + /** + * The cross-tab. + * + * "The average score is 7.5" is a fact nobody can act on. "Support scores 5.8 + * and everyone else is above 7" is a fact with an obvious next step, and this + * is the panel that turns the first into the second. + * + * A table, genuinely — rows of numbers compared across a shared scale is what + * a table is for, and it reads correctly in a screen reader without any of the + * describing an SVG would need. + */ + breakdownPanel(breakdown) { + const metrics = breakdown.groups[0]?.metrics ?? []; + if (!metrics.length) { + return el("div"); + } + const ceilings = /* @__PURE__ */ new Map(); + for (const group of breakdown.groups) { + for (const metric of group.metrics) { + ceilings.set(metric.id, Math.max(ceilings.get(metric.id) ?? 0, metric.mean)); + } + } + const head = el("tr", { + children: [ + el("th", { text: breakdown.label, attrs: { scope: "col" } }), + el("th", { text: "Answers", attrs: { scope: "col" } }), + ...metrics.map((metric) => el("th", { text: metric.label, attrs: { scope: "col" } })) + ] + }); + const rows = breakdown.groups.map( + (group) => el("tr", { + children: [ + el("th", { class: "atfa-table__label", text: group.label, attrs: { scope: "row" } }), + el("td", { class: "atfa-table__count", text: String(group.count) }), + ...group.metrics.map( + (metric) => el("td", { + children: [ + el("div", { + class: "atfa-meter", + children: [ + el("div", { + class: "atfa-meter__fill", + attrs: { + style: `width:${metric.mean / (ceilings.get(metric.id) || 1) * 100}%` + } + }) + ] + }), + el("span", { + class: "atfa-meter__value", + text: null !== metric.nps ? `${metric.mean.toFixed(1)} · NPS ${metric.nps > 0 ? "+" : ""}${metric.nps}` : metric.mean.toFixed(2) + }) + ] + }) + ) + ] + }) + ); + return this.panel(`Broken down by ${breakdown.label.toLowerCase()}`, "", [ + el("table", { + class: "atfa-table", + children: [el("thead", { children: [head] }), el("tbody", { children: rows })] + }) + ]); + } + /** One panel per question. */ + questions(report) { + return el("div", { + class: "atfa-questions", + children: report.fields.map((field) => this.question(field, report.sampled)) + }); + } + /** One question. */ + question(field, sampled) { + const parts = []; + if (field.choices.length) { + const ceiling = field.choices.reduce((most, choice) => Math.max(most, choice.count), 0); + parts.push( + el("ul", { + class: "atfa-bars", + children: field.choices.map( + (choice) => el("li", { + class: "atfa-bars__row", + children: [ + el("span", { class: "atfa-bars__label", text: choice.label }), + el("div", { + class: "atfa-meter", + children: [ + el("div", { + class: "atfa-meter__fill", + attrs: { style: `width:${ceiling ? choice.count / ceiling * 100 : 0}%` } + }) + ] + }), + el("span", { + class: "atfa-bars__value", + text: `${choice.count} ${field.answered ? Math.round(choice.count / field.answered * 100) : 0}%` + }) + ] + }) + ) + }) + ); + } + if (field.numbers) { + parts.push(this.histogram(field)); + } + if (!parts.length) { + parts.push( + el("p", { + class: "atfa-question__note", + text: `${field.answered} of ${sampled} people answered this.` + }) + ); + } + return this.panel(field.label, this.questionSummary(field, sampled), parts); + } + /** The line under a question's title. */ + questionSummary(field, sampled) { + const bits = [`${field.rate}% answered`]; + if (field.numbers) { + bits.push(`mean ${field.numbers.mean}`, `median ${field.numbers.median}`); + } + bits.push(`${field.answered}/${sampled}`); + return bits.join(" · "); + } + /** + * A numeric question's distribution. + * + * The bar the mean falls in is marked, because the interesting cases are the + * ones where it falls in a bar almost nobody chose — which is exactly when + * quoting the mean on its own would have misled. + */ + histogram(field) { + const numbers = field.numbers; + if (!numbers) { + return el("div"); + } + const buckets = []; + for (let value = Math.floor(numbers.min); value <= Math.ceil(numbers.max); value++) { + buckets.push({ value, count: numbers.distribution[String(value)] ?? 0 }); + } + const peak = buckets.reduce((most, bucket) => Math.max(most, bucket.count), 0); + const meanAt = Math.round(numbers.mean); + return el("div", { + class: "atfa-hist", + children: buckets.map( + (bucket) => el("div", { + class: `atfa-hist__col${bucket.value === meanAt ? " is-mean" : ""}`, + attrs: { title: `${bucket.value}: ${bucket.count}` }, + children: [ + el("span", { class: "atfa-hist__count", text: bucket.count ? String(bucket.count) : "" }), + el("div", { + class: "atfa-hist__bar", + attrs: { style: `height:${peak ? Math.max(2, bucket.count / peak * 100) : 0}%` } + }), + el("span", { class: "atfa-hist__tick", text: String(bucket.value) }) + ] + }) + ) + }); + } + /** + * The developer panel. + * + * Deliberately at the bottom, deliberately labelled, and deliberately not + * present at all unless developer mode is on. It writes several hundred + * entries into whatever database this happens to be, which is a fine thing to + * do on a laptop and a terrible one to do by accident. + */ + demoPanel() { + const status = this.demo; + const progress = el("p", { class: "atfa-demo__status", text: this.demoSummary(status) }); + const seed = button("Fill with demo data", async () => { + await this.seedAll(progress); + }); + const remove = button("Remove demo data", async () => { + if (!await confirmAction("Delete the demo survey and every entry it generated?", "Remove demo data")) { + return; + } + progress.textContent = "Removing…"; + try { + const result = await api.demoRemove(); + notify("Demo data removed", `${result.entries} entries and ${result.forms} form.`); + } catch { + notify("Could not remove the demo data", "", "error"); + } + await this.refreshDemo(progress); + }); + return el("div", { + class: "atfa-demo", + attrs: { "data-atfa-demo": "" }, + children: [ + el("h3", { class: "atfa-demo__title", text: "Developer" }), + el("p", { + class: "atfa-demo__note", + text: "Generates a team pulse survey and several hundred submissions through the real submission pipeline, so this report has something to report on. Everything it makes is tagged, and removing it deletes exactly that and nothing else." + }), + progress, + el("div", { class: "atfa-demo__actions", children: [seed, remove] }) + ] + }); + } + /** What the developer panel says about the current state. */ + demoSummary(status) { + if (!status || !status.formId) { + return "No demo data on this site."; + } + return `${status.title}: ${status.entries} of ${status.target} submissions.`; + } + /** + * Generates every remaining chunk. + * + * A loop of small requests rather than one big one. Each chunk is a few dozen + * passes through the whole submission pipeline, and the server caps what it + * will do per call — so this asks repeatedly, which is also what gives the + * count something true to report while it runs. + */ + async seedAll(progress) { + let guard = 0; + let failures = 0; + for (; ; ) { + try { + const status = await api.demoSeed(); + this.demo = status; + failures = 0; + progress.textContent = `Generating… ${status.entries} of ${status.target}`; + if (status.remaining < 1) { + notify("Demo data ready", `${status.entries} submissions.`); + break; + } + } catch { + failures += 1; + if (failures >= 3) { + progress.textContent = "Stopped early. Press it again to carry on from here."; + notify("Could not finish the demo data", "Some submissions were made. Try again to continue.", "error"); + break; + } + } + guard += 1; + if (guard > 200) { + break; + } + } + await this.refreshDemo(progress); + if (this.demo?.formId) { + try { + this.forms = await api.listForms(); + } catch { + } + this.formId = this.demo.formId; + this.dimension = ""; + } + await this.load(); + } + /** Re-reads the demo status and redraws the line that shows it. */ + async refreshDemo(progress) { + try { + this.demo = await api.demoStatus(); + } catch { + this.demo = null; + } + progress.textContent = this.demoSummary(this.demo); + } + /** A titled block. */ + panel(title, note, children) { + return el("section", { + class: "atfa-panel", + children: [ + el("header", { + class: "atfa-panel__head", + children: [ + el("h3", { class: "atfa-panel__title", text: title }), + note ? el("p", { class: "atfa-panel__note", text: note }) : null + ] + }), + ...children + ] + }); + } + /** Scrolls the developer panel into view. */ + revealDemo() { + this.body.querySelector("[data-atfa-demo]")?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + } + let mounted = null; + function mountAnalytics() { + const root = document.querySelector("[data-atfa-root]"); + if (!root || root.dataset.atfaMounted) { + return; + } + root.dataset.atfaMounted = "1"; + mounted = new AnalyticsWindow(root); + void mounted.start(); + } + let wantsDemo = false; + document.addEventListener("atf-open-demo-panel", () => { + wantsDemo = true; + window.setTimeout(() => { + mounted?.revealDemo(); + wantsDemo = false; + }, 600); + }); + document.addEventListener("os-window-content-loaded", () => { + mountAnalytics(); + if (wantsDemo) { + window.setTimeout(() => mounted?.revealDemo(), 400); + } + }); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mountAnalytics); + } else { + mountAnalytics(); + } + exports.mountAnalytics = mountAnalytics; + Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); + return exports; +}({}); diff --git a/assets/js/analytics.min.js b/assets/js/analytics.min.js new file mode 100644 index 0000000..842bc43 --- /dev/null +++ b/assets/js/analytics.min.js @@ -0,0 +1 @@ +var allTerrainFormsAnalytics=function($){"use strict";function k(){return window.wp?.os??null}const c=window.allTerrainForms;class f extends Error{constructor(e,t,a=""){super(e),this.name="ApiError",this.status=t,this.code=a}}async function _(s,e={}){if(!c?.restUrl)throw new f("AllTerrain Forms is not configured on this page.",0);const t=`${c.restUrl}${s}`,a={"Content-Type":"application/json",...e.headers??{}};c.nonce&&(a["X-WP-Nonce"]=c.nonce);const i=k(),o=await(i?.fetch?(l,u)=>i.fetch(l,u,{source:"allterrain-forms"}):(l,u)=>fetch(l,u))(t,{credentials:"same-origin",...e,headers:a});if(!o.ok){let l=`Request failed with status ${o.status}.`,u="";try{const T=await o.json();l=T.message??l,u=T.code??""}catch{}throw new f(l,o.status,u)}if(o.status!==204)return await o.json()}const d=s=>_(s);async function M(s){if(!c?.wpRestUrl)throw new f("AllTerrain Forms is not configured on this page.",0);const e=c.nonce?{"X-WP-Nonce":c.nonce}:{},t=k(),a=`${c.wpRestUrl}${s}`,i=t?.fetch?await t.fetch(a,{credentials:"same-origin",headers:e},{source:"allterrain-forms"}):await fetch(a,{credentials:"same-origin",headers:e});if(!i.ok)throw new f(`Request failed with status ${i.status}.`,i.status);return await i.json()}function F(s){if(!s||!s.includes("&"))return s;const e=document.createElement("textarea");return e.innerHTML=s,e.value}const h=(s,e)=>_(s,{method:"POST",body:JSON.stringify(e)}),p=s=>_(s,{method:"DELETE"});function E(s){const e=new URLSearchParams;for(const[a,i]of Object.entries(s))i===void 0||i===""||i===!1||e.set(a,String(i));const t=e.toString();return t?`?${t}`:""}const m={config:()=>d("/config"),listForms:()=>d("/forms"),getForm:s=>d(`/forms/${s}`),createForm:s=>h("/forms",s),updateForm:(s,e)=>h(`/forms/${s}`,e),duplicateForm:s=>h(`/forms/${s}/duplicate`,{}),deleteForm:s=>p(`/forms/${s}`),preview:(s,e)=>h(`/forms/${s}/preview`,e),pages:()=>M("wp/v2/pages?per_page=100&status=publish&orderby=title&order=asc&_fields=id,title").then(s=>s.map(e=>({id:e.id,title:F(e.title?.rendered??"")||`#${e.id}`}))),mergeTags:s=>d(`/forms/${s}/merge-tags`).then(e=>e.groups),analytics:(s,e="")=>d(`/forms/${s}/analytics${e?`?dimension=${encodeURIComponent(e)}`:""}`),demoStatus:()=>d("/demo"),demoSeed:s=>h("/demo",s?{count:s}:{}),demoRemove:()=>p("/demo"),listEntries:s=>d(`/entries${E(s)}`),getEntry:s=>d(`/entries/${s}`),updateEntry:(s,e)=>h(`/entries/${s}`,e),deleteEntry:s=>p(`/entries/${s}`),exportEntries:s=>d(`/entries/export${E(s)}`),listThemes:()=>d("/themes"),saveTheme:s=>h("/themes",s),deleteTheme:s=>p(`/themes/${s}`)};function n(s,e={}){const t=document.createElement(s);e.class&&(t.className=e.class),e.text!==void 0&&(t.textContent=e.text),e.html!==void 0&&(t.innerHTML=e.html),e.title&&(t.title=e.title),e.type&&"type"in t&&(t.type=e.type),e.value!==void 0&&"value"in t&&(t.value=e.value),e.placeholder!==void 0&&"placeholder"in t&&(t.placeholder=e.placeholder),e.href&&"href"in t&&(t.href=e.href);for(const[a,i]of Object.entries(e.attrs??{}))i===void 0||i===!1||t.setAttribute(a,i===!0?"":String(i));Object.assign(t.style,e.style??{});for(const[a,i]of Object.entries(e.on??{}))t.addEventListener(a,i);for(const a of e.children??[])a==null||a===!1||t.append(a);return t}function b(s){s.replaceChildren()}function A(s,e,t){if(x("os-select")&&x("os-option")){const a=document.createElement("os-select");a.setAttribute("value",s),a.classList.add("atfb-field");for(const i of e){const r=document.createElement("os-option");r.setAttribute("value",i.value),r.textContent=i.label,a.append(r)}return a.addEventListener("os-pick",i=>{t(String(i.detail?.value??""))}),a}return n("select",{class:"atfb-input atfb-select",on:{change:a=>t(a.target.value)},children:e.map(a=>n("option",{value:a.value,text:a.label,attrs:{selected:a.value===s}}))})}function P(s,e,t="secondary",a){const i=[null,n("span",{text:s})];if(x("os-button")){const r=document.createElement("os-button");r.setAttribute("variant",t),r.setAttribute("type","button"),r.classList.add("atfb-button",`atfb-button--${t}`),r.addEventListener("click",e);for(const o of i)o&&r.append(o);return r}return n("button",{class:`atfb-button atfb-button--${t}`,type:"button",on:{click:e},children:i})}function x(s){return typeof customElements<"u"&&!!customElements.get(s)}const q=["os-button","os-checkbox-label","os-number-field","os-select","os-option","os-segmented","os-segment","os-color-field","os-empty-state"];let v=null;function D(){if(v)return v;const s=window.wp?.os;return v=s?.loadComponents?s.loadComponents(q).catch(()=>{}):Promise.resolve(),v}async function L(s,e=""){const t=window.wp?.os;return t?.confirm?t.confirm({title:e,message:s,danger:!0}):window.confirm(s)}function y(s,e="",t="info"){const a=window.wp?.os;if(a?.notify){a.notify({title:s,body:e,type:t});return}console.info(`[AllTerrain Forms] ${s}${e?`: ${e}`:""}`)}const C=[{key:"detractors",label:"Detractors",hint:"0–6"},{key:"passives",label:"Passives",hint:"7–8"},{key:"promoters",label:"Promoters",hint:"9–10"}];class N{constructor(e){this.forms=[],this.formId=0,this.dimension="",this.report=null,this.demo=null,this.demoAvailable=!1,this.bar=e.querySelector("[data-atfa-bar]")??n("div"),this.body=e.querySelector("[data-atfa-body]")??n("div")}async start(){await D();try{this.forms=await m.listForms()}catch{this.fail("Could not load your forms.");return}if(!this.forms.length){this.fail("There are no forms yet. Build one, collect a few submissions, and this fills in.");return}if(c?.devMode&&c?.canEdit)try{this.demo=await m.demoStatus(),this.demoAvailable=!0}catch{this.demoAvailable=!1}this.formId=this.forms[0].id,await this.load()}async load(){try{this.report=await m.analytics(this.formId,this.dimension)}catch{this.fail("Could not load that report.");return}this.dimension=this.report.breakdown?.id??"",this.renderBar(),this.render()}fail(e){b(this.bar),b(this.body),this.body.append(n("p",{class:"atfa__empty",text:e}))}renderBar(){b(this.bar),this.bar.append(A(String(this.formId),this.forms.map(t=>({value:String(t.id),label:t.title||`#${t.id}`})),t=>{this.formId=Number(t),this.dimension="",this.load()}));const e=this.report?.dimensions??[];e.length>1&&this.bar.append(n("span",{class:"atfa__bar-label",text:"Group by"}),A(this.dimension,e.map(t=>({value:t.id,label:t.label})),t=>{this.dimension=t,this.load()}))}render(){const e=this.report;if(b(this.body),!!e){if(this.body.append(this.kpis(e)),e.submissions<1&&e.sampled<1)this.body.append(n("p",{class:"atfa__empty",text:"No submissions yet. Everything below fills in as they arrive."}));else{this.body.append(this.timeline(e));for(const t of e.fields)t.nps&&this.body.append(this.npsPanel(t));e.breakdown&&e.breakdown.groups.length&&this.body.append(this.breakdownPanel(e.breakdown)),this.body.append(this.questions(e))}this.demoAvailable&&this.body.append(this.demoPanel())}}kpis(e){const t=[{label:"Submissions",value:String(e.submissions)},{label:"Views",value:String(e.views)},{label:"Conversion",value:`${e.conversion}%`,hint:"of people who saw it"},{label:"Completion",value:`${e.completion}%`,hint:"of people who started"},{label:"Unread",value:String(e.unread)},{label:"Spam",value:String(e.spam)}];return n("div",{class:"atfa-kpis",children:t.map(a=>n("div",{class:"atfa-kpi",children:[n("span",{class:"atfa-kpi__value",text:a.value}),n("span",{class:"atfa-kpi__label",text:a.label}),a.hint?n("span",{class:"atfa-kpi__hint",text:a.hint}):null]}))})}timeline(e){const t=e.timeline,a=t.reduce((r,o)=>Math.max(r,o.count),0),i=t.reduce((r,o)=>r+o.count,0);return this.panel("When they answered",`${i} in the last ${t.length} days · busiest day ${a}`,[n("div",{class:"atfa-timeline",children:t.map(r=>n("div",{class:`atfa-timeline__day${r.count?"":" is-empty"}`,attrs:{style:`height:${a?Math.max(2,r.count/a*100):0}%`,title:`${r.date}: ${r.count}`}}))}),n("div",{class:"atfa-timeline__axis",children:[n("span",{text:t[0]?.date??""}),n("span",{text:t[t.length-1]?.date??""})]})])}npsPanel(e){const t=e.nps;if(!t)return n("div");const a=i=>t.responses?i/t.responses*100:0;return this.panel(e.label,`${t.responses} answers · Net Promoter Score`,[n("div",{class:"atfa-nps",children:[n("div",{class:`atfa-nps__score is-${t.score>=50?"great":t.score>=0?"ok":"poor"}`,children:[n("span",{class:"atfa-nps__number",text:`${t.score>0?"+":""}${t.score}`}),n("span",{class:"atfa-nps__caption",text:"NPS"})]}),n("div",{class:"atfa-nps__detail",children:[n("div",{class:"atfa-nps__bar",children:C.map(i=>n("div",{class:`atfa-nps__band is-${i.key}`,attrs:{style:`width:${a(t[i.key])}%`,title:`${i.label} (${i.hint}): ${t[i.key]}`}}))}),n("div",{class:"atfa-nps__legend",children:C.map(i=>n("span",{class:`atfa-nps__key is-${i.key}`,children:[n("strong",{text:String(t[i.key])}),n("span",{text:` ${i.label} `}),n("small",{text:i.hint})]}))})]})]})])}breakdownPanel(e){const t=e.groups[0]?.metrics??[];if(!t.length)return n("div");const a=new Map;for(const o of e.groups)for(const l of o.metrics)a.set(l.id,Math.max(a.get(l.id)??0,l.mean));const i=n("tr",{children:[n("th",{text:e.label,attrs:{scope:"col"}}),n("th",{text:"Answers",attrs:{scope:"col"}}),...t.map(o=>n("th",{text:o.label,attrs:{scope:"col"}}))]}),r=e.groups.map(o=>n("tr",{children:[n("th",{class:"atfa-table__label",text:o.label,attrs:{scope:"row"}}),n("td",{class:"atfa-table__count",text:String(o.count)}),...o.metrics.map(l=>n("td",{children:[n("div",{class:"atfa-meter",children:[n("div",{class:"atfa-meter__fill",attrs:{style:`width:${l.mean/(a.get(l.id)||1)*100}%`}})]}),n("span",{class:"atfa-meter__value",text:l.nps!==null?`${l.mean.toFixed(1)} · NPS ${l.nps>0?"+":""}${l.nps}`:l.mean.toFixed(2)})]}))]}));return this.panel(`Broken down by ${e.label.toLowerCase()}`,"",[n("table",{class:"atfa-table",children:[n("thead",{children:[i]}),n("tbody",{children:r})]})])}questions(e){return n("div",{class:"atfa-questions",children:e.fields.map(t=>this.question(t,e.sampled))})}question(e,t){const a=[];if(e.choices.length){const i=e.choices.reduce((r,o)=>Math.max(r,o.count),0);a.push(n("ul",{class:"atfa-bars",children:e.choices.map(r=>n("li",{class:"atfa-bars__row",children:[n("span",{class:"atfa-bars__label",text:r.label}),n("div",{class:"atfa-meter",children:[n("div",{class:"atfa-meter__fill",attrs:{style:`width:${i?r.count/i*100:0}%`}})]}),n("span",{class:"atfa-bars__value",text:`${r.count} ${e.answered?Math.round(r.count/e.answered*100):0}%`})]}))}))}return e.numbers&&a.push(this.histogram(e)),a.length||a.push(n("p",{class:"atfa-question__note",text:`${e.answered} of ${t} people answered this.`})),this.panel(e.label,this.questionSummary(e,t),a)}questionSummary(e,t){const a=[`${e.rate}% answered`];return e.numbers&&a.push(`mean ${e.numbers.mean}`,`median ${e.numbers.median}`),a.push(`${e.answered}/${t}`),a.join(" · ")}histogram(e){const t=e.numbers;if(!t)return n("div");const a=[];for(let o=Math.floor(t.min);o<=Math.ceil(t.max);o++)a.push({value:o,count:t.distribution[String(o)]??0});const i=a.reduce((o,l)=>Math.max(o,l.count),0),r=Math.round(t.mean);return n("div",{class:"atfa-hist",children:a.map(o=>n("div",{class:`atfa-hist__col${o.value===r?" is-mean":""}`,attrs:{title:`${o.value}: ${o.count}`},children:[n("span",{class:"atfa-hist__count",text:o.count?String(o.count):""}),n("div",{class:"atfa-hist__bar",attrs:{style:`height:${i?Math.max(2,o.count/i*100):0}%`}}),n("span",{class:"atfa-hist__tick",text:String(o.value)})]}))})}demoPanel(){const e=this.demo,t=n("p",{class:"atfa-demo__status",text:this.demoSummary(e)}),a=P("Fill with demo data",async()=>{await this.seedAll(t)}),i=P("Remove demo data",async()=>{if(await L("Delete the demo survey and every entry it generated?","Remove demo data")){t.textContent="Removing…";try{const r=await m.demoRemove();y("Demo data removed",`${r.entries} entries and ${r.forms} form.`)}catch{y("Could not remove the demo data","","error")}await this.refreshDemo(t)}});return n("div",{class:"atfa-demo",attrs:{"data-atfa-demo":""},children:[n("h3",{class:"atfa-demo__title",text:"Developer"}),n("p",{class:"atfa-demo__note",text:"Generates a team pulse survey and several hundred submissions through the real submission pipeline, so this report has something to report on. Everything it makes is tagged, and removing it deletes exactly that and nothing else."}),t,n("div",{class:"atfa-demo__actions",children:[a,i]})]})}demoSummary(e){return!e||!e.formId?"No demo data on this site.":`${e.title}: ${e.entries} of ${e.target} submissions.`}async seedAll(e){let t=0,a=0;for(;;){try{const i=await m.demoSeed();if(this.demo=i,a=0,e.textContent=`Generating… ${i.entries} of ${i.target}`,i.remaining<1){y("Demo data ready",`${i.entries} submissions.`);break}}catch{if(a+=1,a>=3){e.textContent="Stopped early. Press it again to carry on from here.",y("Could not finish the demo data","Some submissions were made. Try again to continue.","error");break}}if(t+=1,t>200)break}if(await this.refreshDemo(e),this.demo?.formId){try{this.forms=await m.listForms()}catch{}this.formId=this.demo.formId,this.dimension=""}await this.load()}async refreshDemo(e){try{this.demo=await m.demoStatus()}catch{this.demo=null}e.textContent=this.demoSummary(this.demo)}panel(e,t,a){return n("section",{class:"atfa-panel",children:[n("header",{class:"atfa-panel__head",children:[n("h3",{class:"atfa-panel__title",text:e}),t?n("p",{class:"atfa-panel__note",text:t}):null]}),...a]})}revealDemo(){this.body.querySelector("[data-atfa-demo]")?.scrollIntoView({behavior:"smooth",block:"start"})}}let w=null;function g(){const s=document.querySelector("[data-atfa-root]");!s||s.dataset.atfaMounted||(s.dataset.atfaMounted="1",w=new N(s),w.start())}let S=!1;return document.addEventListener("atf-open-demo-panel",()=>{S=!0,window.setTimeout(()=>{w?.revealDemo(),S=!1},600)}),document.addEventListener("os-window-content-loaded",()=>{g(),S&&window.setTimeout(()=>w?.revealDemo(),400)}),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",g):g(),$.mountAnalytics=g,Object.defineProperty($,Symbol.toStringTag,{value:"Module"}),$}({}); diff --git a/assets/js/builder.js b/assets/js/builder.js index 1b810b2..ebc65f3 100644 --- a/assets/js/builder.js +++ b/assets/js/builder.js @@ -353,7 +353,20 @@ var allTerrainFormsBuilder = function(exports) { })) ), mergeTags: (id) => get(`/forms/${id}/merge-tags`).then((response) => response.groups), - analytics: (id) => get(`/forms/${id}/analytics`), + analytics: (id, dimension = "") => get( + `/forms/${id}/analytics${dimension ? `?dimension=${encodeURIComponent(dimension)}` : ""}` + ), + /** + * The demo-data tools. + * + * Every one of these 404s unless developer mode is on, which is why the + * window asks for the status before drawing the panel rather than drawing the + * panel and letting the buttons fail. + */ + demoStatus: () => get("/demo"), + /** Generates one chunk. Called until `remaining` reaches zero. */ + demoSeed: (count) => post("/demo", count ? { count } : {}), + demoRemove: () => del("/demo"), listEntries: (params) => get(`/entries${query(params)}`), getEntry: (id) => get(`/entries/${id}`), updateEntry: (id, body) => post(`/entries/${id}`, body), diff --git a/assets/js/builder.min.js b/assets/js/builder.min.js index f130640..6eb926c 100644 --- a/assets/js/builder.min.js +++ b/assets/js/builder.min.js @@ -1,3 +1,3 @@ -var allTerrainFormsBuilder=function(W){"use strict";class ot{constructor(){this.targets=[],this.active=null,this.lastEndMs=0}start(e){if(this.active||e.origin.button!==0)return null;const{payload:t,origin:s}=e,i=s.clientX,a=s.clientY;let o=!1,l=!1,c=null,d=null,u=0,L=0;const w=()=>{document.removeEventListener("pointermove",E),document.removeEventListener("pointerup",R),document.removeEventListener("pointercancel",X),document.removeEventListener("keydown",ie),window.removeEventListener("blur",X),c?.remove(),c=null,t.source.classList.remove("atf-is-dragging"),d?.onLeave?.(m),d=null,this.active=null,this.lastEndMs=Date.now()},m={payload:t,isFinished:()=>l,cancel:(_="caller")=>{l||(l=!0,w(),e.onCancel?.(_))}},y=_=>{o=!0,t.source.classList.add("atf-is-dragging");const A=t.source.getBoundingClientRect();u=t.ghost?.offsetX??i-A.left,L=t.ghost?.offsetY??a-A.top,c=t.ghost?.element??t.source.cloneNode(!0),c.classList.add("atf-drag-ghost"),c.style.width=`${A.width}px`,document.body.appendChild(c),S(_)},S=_=>{c&&(c.style.transform=`translate3d(${_.clientX-u}px, ${_.clientY-L}px, 0)`)},E=_=>{if(l)return;if(!o){if(Math.hypot(_.clientX-i,_.clientY-a)<4)return;y(_)}S(_);const A=this.hitTest(_.clientX,_.clientY);A!==d&&(d?.onLeave?.(m),d=A,d?.onEnter?.(m))},R=_=>{if(l)return;if(!o){l=!0,w(),e.onClickOnly?.();return}const A=d;if(l=!0,w(),A&&A.accept(t)){e.onCommit?.(A),A.onDrop(m,{clientX:_.clientX,clientY:_.clientY});return}e.onCancel?.(A?"rejected":"no-target")},X=()=>m.cancel("pointercancel"),ie=_=>{_.key==="Escape"&&m.cancel("escape")};return document.addEventListener("pointermove",E),document.addEventListener("pointerup",R),document.addEventListener("pointercancel",X),document.addEventListener("keydown",ie),window.addEventListener("blur",X),this.active=m,m}registerDropTarget(e){return this.targets=this.targets.filter(t=>t.id!==e.id),this.targets.push(e),()=>{this.targets=this.targets.filter(t=>t.id!==e.id)}}isDragging(){return this.active!==null}recentlyEndedDrag(e=500){return Date.now()-this.lastEndMso.right||to.bottom)continue;const l=lt(a.element);if(l>i){s=a,i=l;continue}l===i&&s&&ct(a.element,s.element)&&(s=a)}return s}}function lt(n){let e=0,t=n;for(;t;)e++,t=t.parentElement;return e}function ct(n,e){return(e.compareDocumentPosition(n)&Node.DOCUMENT_POSITION_FOLLOWING)!==0}function oe(){return window.wp?.os??null}let le=null;function U(){const n=oe();return n?.dragManager?n.dragManager:(le||(le=new ot),le)}function dt(n){const e=i=>{const a=i.detail?.payload;return a&&n.includes(a.type)?a.source:null},t=i=>e(i)?.classList.add("atf-is-dragging"),s=i=>e(i)?.classList.remove("atf-is-dragging");return document.addEventListener("os.drag.start",t),document.addEventListener("os.drag.end",s),()=>{document.removeEventListener("os.drag.start",t),document.removeEventListener("os.drag.end",s)}}function Te(n,e,t,s,i){const a=e.getBoundingClientRect();return i&&(i.style.width=`${Math.round(a.width)}px`,i.style.maxWidth=`${Math.round(a.width)}px`,i.style.boxSizing="border-box"),{type:n,source:e,data:t,ghost:{element:i,offsetX:s.clientX-a.left,offsetY:s.clientY-a.top,hint:{neutral:"",accept:"",reject:"",hidden:!0}}}}function $e(n,e,t,s){const i=Array.from(n.querySelectorAll(e)).filter(a=>a!==s&&!a.classList.contains("atf-drag-ghost"));for(let a=0;ai.fetch(l,c,{source:"allterrain-forms"}):(l,c)=>fetch(l,c))(t,{credentials:"same-origin",...e,headers:s});if(!o.ok){let l=`Request failed with status ${o.status}.`,c="";try{const d=await o.json();l=d.message??l,c=d.code??""}catch{}throw new ee(l,o.status,c)}if(o.status!==204)return await o.json()}const P=n=>ce(n);async function ht(n){if(!O?.wpRestUrl)throw new ee("AllTerrain Forms is not configured on this page.",0);const e=O.nonce?{"X-WP-Nonce":O.nonce}:{},t=oe(),s=`${O.wpRestUrl}${n}`,i=t?.fetch?await t.fetch(s,{credentials:"same-origin",headers:e},{source:"allterrain-forms"}):await fetch(s,{credentials:"same-origin",headers:e});if(!i.ok)throw new ee(`Request failed with status ${i.status}.`,i.status);return await i.json()}function ut(n){if(!n||!n.includes("&"))return n;const e=document.createElement("textarea");return e.innerHTML=n,e.value}const Y=(n,e)=>ce(n,{method:"POST",body:JSON.stringify(e)}),de=n=>ce(n,{method:"DELETE"});function Ae(n){const e=new URLSearchParams;for(const[s,i]of Object.entries(n))i===void 0||i===""||i===!1||e.set(s,String(i));const t=e.toString();return t?`?${t}`:""}const C={config:()=>P("/config"),listForms:()=>P("/forms"),getForm:n=>P(`/forms/${n}`),createForm:n=>Y("/forms",n),updateForm:(n,e)=>Y(`/forms/${n}`,e),duplicateForm:n=>Y(`/forms/${n}/duplicate`,{}),deleteForm:n=>de(`/forms/${n}`),preview:(n,e)=>Y(`/forms/${n}/preview`,e),pages:()=>ht("wp/v2/pages?per_page=100&status=publish&orderby=title&order=asc&_fields=id,title").then(n=>n.map(e=>({id:e.id,title:ut(e.title?.rendered??"")||`#${e.id}`}))),mergeTags:n=>P(`/forms/${n}/merge-tags`).then(e=>e.groups),analytics:n=>P(`/forms/${n}/analytics`),listEntries:n=>P(`/entries${Ae(n)}`),getEntry:n=>P(`/entries/${n}`),updateEntry:(n,e)=>Y(`/entries/${n}`,e),deleteEntry:n=>de(`/entries/${n}`),exportEntries:n=>P(`/entries/export${Ae(n)}`),listThemes:()=>P("/themes"),saveTheme:n=>Y("/themes",n),deleteTheme:n=>de(`/themes/${n}`)};function r(n,e={}){const t=document.createElement(n);e.class&&(t.className=e.class),e.text!==void 0&&(t.textContent=e.text),e.html!==void 0&&(t.innerHTML=e.html),e.title&&(t.title=e.title),e.type&&"type"in t&&(t.type=e.type),e.value!==void 0&&"value"in t&&(t.value=e.value),e.placeholder!==void 0&&"placeholder"in t&&(t.placeholder=e.placeholder),e.href&&"href"in t&&(t.href=e.href);for(const[s,i]of Object.entries(e.attrs??{}))i===void 0||i===!1||t.setAttribute(s,i===!0?"":String(i));Object.assign(t.style,e.style??{});for(const[s,i]of Object.entries(e.on??{}))t.addEventListener(s,i);for(const s of e.children??[])s==null||s===!1||t.append(s);return t}function D(n){n.replaceChildren()}function F(n){return r("span",{class:`dashicons ${n.startsWith("dashicons-")?n:`dashicons-${n}`}`,attrs:{"aria-hidden":"true"}})}function p(n,e,t){const s=e.id||`atf-c-${Math.random().toString(36).slice(2,9)}`;return e.id=s,r("div",{class:"atfb-row",children:[r("label",{class:"atfb-row__label",text:n,attrs:{for:s}}),e,t?r("p",{class:"atfb-row__hint",text:t}):null]})}function v(n,e,t=""){return r("input",{class:"atfb-input",type:"text",value:n,placeholder:t,on:{input:s=>e(s.target.value)}})}function N(n,e){if(J("os-number-field")){const t=document.createElement("os-number-field");return t.setAttribute("value",n),t.classList.add("atfb-field"),t.addEventListener("os-input-change",s=>{e(String(s.detail?.value??""))}),t}return r("input",{class:"atfb-input",type:"number",value:n,on:{input:t=>e(t.target.value)}})}function te(n,e,t=4){const s=r("textarea",{class:"atfb-input atfb-input--area",attrs:{rows:t},on:{input:i=>e(i.target.value)}});return s.value=n,s}function $(n,e,t){if(J("os-select")&&J("os-option")){const s=document.createElement("os-select");s.setAttribute("value",n),s.classList.add("atfb-field");for(const i of e){const a=document.createElement("os-option");a.setAttribute("value",i.value),a.textContent=i.label,s.append(a)}return s.addEventListener("os-pick",i=>{t(String(i.detail?.value??""))}),s}return r("select",{class:"atfb-input atfb-select",on:{change:s=>t(s.target.value)},children:e.map(s=>r("option",{value:s.value,text:s.label,attrs:{selected:s.value===n}}))})}function T(n,e,t){if(J("os-checkbox-label")){const i=document.createElement("os-checkbox-label");return i.setAttribute("label",n),i.classList.add("atfb-check"),e&&i.setAttribute("checked",""),i.addEventListener("os-checkbox-change",a=>{t(!!a.detail?.checked)}),i}const s=r("input",{type:"checkbox",on:{change:i=>t(i.target.checked)}});return s.checked=e,r("label",{class:"atfb-check",children:[s,r("span",{text:n})]})}function k(n,e,t="secondary",s){const i=[s?F(s):null,r("span",{text:n})];if(J("os-button")){const a=document.createElement("os-button");a.setAttribute("variant",t),a.setAttribute("type","button"),a.classList.add("atfb-button",`atfb-button--${t}`),a.addEventListener("click",e);for(const o of i)o&&a.append(o);return a}return r("button",{class:`atfb-button atfb-button--${t}`,type:"button",on:{click:e},children:i})}function J(n){return typeof customElements<"u"&&!!customElements.get(n)}const pt=["os-button","os-checkbox-label","os-number-field","os-select","os-option","os-segmented","os-segment","os-color-field","os-empty-state"];let se=null;function ft(){if(se)return se;const n=window.wp?.os;return se=n?.loadComponents?n.loadComponents(pt).catch(()=>{}):Promise.resolve(),se}async function he(n,e=""){const t=window.wp?.os;return t?.confirm?t.confirm({title:e,message:n,danger:!0}):window.confirm(n)}function I(n,e="",t="info"){const s=window.wp?.os;if(s?.notify){s.notify({title:n,body:e,type:t});return}console.info(`[AllTerrain Forms] ${n}${e?`: ${e}`:""}`)}function mt(n){let e=0,t;return(...s)=>{t=s,!e&&(e=window.requestAnimationFrame(()=>{e=0,n(...t)}))}}function De(n,e){let t=0;return(...s)=>{window.clearTimeout(t),t=window.setTimeout(()=>n(...s),e)}}function bt(n){try{return window.localStorage.getItem(n)??""}catch{return""}}function gt(n,e){try{window.localStorage.setItem(n,e)}catch{}}function Me(){const n=[window];try{window.parent&&window.parent!==window&&n.push(window.parent)}catch{}return n}function vt(n){const e=n.wp?.os?.windowManager?.getAll?.()??[];for(const t of e){const s=t.iframe??t.element?.querySelector?.("iframe");if(s&&s.contentWindow===window)return t}return null}const wt=4e3,yt=100;function xt(n,e){const t=Date.now()+wt,s=()=>{const i=vt(n);if(i&&i.id!==e){typeof i.close=="function"?i.close():n.wp?.os?.windowManager?.remove?.(i.id);return}!i&&Date.now()0?{form:t}:void 0;t>0&&Et(t);for(const i of Me())if(i.wp?.os?.openWindow?.(e,{source:"handoff",params:s})){window.setTimeout(()=>xt(i,e),0);return}}function kt(){document.addEventListener("click",n=>{const e=n.target?.closest?.("[data-atf-open-window]");if(!e)return;n.preventDefault();const t=e.getAttribute("data-atf-open-window")??"";for(const s of Me())if(s.wp?.os?.openWindow?.(t,{source:"handoff"}))return})}const St="allterrain-forms/requested-form";function Et(n){try{window.sessionStorage.setItem(St,String(n))}catch{}}const Oe={is:"is",is_not:"is not",contains:"contains",not_contains:"does not contain",starts_with:"starts with",ends_with:"ends with",greater:"is more than",less:"is less than",greater_equal:"is at least",less_equal:"is at most",empty:"is empty",not_empty:"has any answer"},Fe=["empty","not_empty"];function qe(n,e){const t=n.find(s=>s.id===e);return t?t.label||"an untitled question":null}function Ie(n,e,t){return n.find(a=>a.id===e)?.choices?.find(a=>a.value===t)?.label||t}function Lt(n,e){const t=Oe[n.operator]??String(n.operator);if(Fe.includes(n.operator))return t;const s=Ie(e,n.field,n.value);return`${n.operator==="is"?"":`${t} `}${s!==""?s:"(nothing)"}`}function Ne(n,e){const t=qe(e,n.field),s=Oe[n.operator]??String(n.operator);if(t===null)return[{kind:"field",text:"a question that no longer exists",fieldId:n.field,missing:!0}];const i=[{kind:"field",text:t,fieldId:n.field,missing:!1},{kind:"operator",text:s}];if(Fe.includes(n.operator))return i;const a=Ie(e,n.field,n.value);return i.push({kind:"value",text:a!==""?a:"(nothing)"}),i}function Ct(n,e){const t=n.logic;if(!t?.enabled||!t.rules.length)return[];const s=[{kind:"verb",text:t.action==="hide"?"Hidden when":"Shown when"}];return t.rules.forEach((i,a)=>{a>0&&s.push({kind:"join",text:t.match==="all"?"and":"or"}),s.push(...Ne(i,e))}),s}function Pe(n){return n.map(e=>e.text).join(" ")}function Tt(n,e){return Pe(Ne(n,e))}function ue(n){const e=[];for(const t of n){const s=t.logic;if(s?.enabled)for(const i of s.rules)!i.field||i.field===t.id||e.push({from:i.field,to:t.id,label:Tt(i,n),short:Lt(i,n),action:s.action,broken:qe(n,i.field)===null})}return e}function $t(n){const e=new Map,t=new Set;for(const s of ue(n)){const i=`${s.from}->${s.to}`;s.broken||t.has(i)||(t.add(i),e.set(s.from,(e.get(s.from)??0)+1))}return e}const Be=10,Re=22,z="http://www.w3.org/2000/svg";class At{constructor(e){this.edges=[],this.teardowns=[],this.frame=0,this.host=e,this.svg=document.createElementNS(z,"svg"),this.svg.setAttribute("class","atfb-logicmap"),this.svg.setAttribute("aria-hidden","true"),this.svg.setAttribute("focusable","false"),e.append(this.svg);const t=()=>this.schedule();window.addEventListener("resize",t),this.teardowns.push(()=>window.removeEventListener("resize",t));const s=e.closest(".atfb__canvas")??e;if(s.addEventListener("scroll",t,{passive:!0}),this.teardowns.push(()=>s.removeEventListener("scroll",t)),typeof ResizeObserver<"u"){const i=new ResizeObserver(t);i.observe(e),this.teardowns.push(()=>i.disconnect())}}setEdges(e){this.edges=e,this.schedule()}highlight(e){this.svg.classList.toggle("is-focused",!!e),this.svg.querySelectorAll("[data-from]").forEach(t=>{const s=e&&(t.dataset.from===e||t.dataset.to===e);t.classList.toggle("is-lit",!!s)})}destroy(){this.teardowns.forEach(e=>e()),this.teardowns=[],this.svg.remove(),this.frame&&cancelAnimationFrame(this.frame)}schedule(){this.frame||(this.frame=requestAnimationFrame(()=>{this.frame=0,this.draw()}))}draw(){this.svg.replaceChildren();const e=this.host.getBoundingClientRect();this.svg.setAttribute("viewBox",`0 0 ${e.width} ${e.height}`),this.svg.setAttribute("width",String(e.width)),this.svg.setAttribute("height",String(e.height));const t=new Map;for(const s of this.edges){const i=this.cardRect(s.from),a=this.cardRect(s.to);if(!i||!a)continue;const o=t.get(s.from)??0;t.set(s.from,o+1),this.drawEdge(s,i,a,e,o)}}cardRect(e){const t=this.host.querySelector(`[data-atfb-card="${CSS.escape(e)}"]`);return t?t.getBoundingClientRect():null}drawEdge(e,t,s,i,a){const o=t.right-i.left,l=t.top+t.height/2-i.top,c=s.right-i.left,d=s.top+s.height/2-i.top,u=Math.max(0,i.width-Be-Math.max(o,c)),L=22+a*11+Math.min(22,Math.abs(d-l)/8),w=Math.max(12,Math.min(L,u*.4)),m=document.createElementNS(z,"path");m.setAttribute("d",`M ${o} ${l} C ${o+w} ${l}, ${c+w} ${d}, ${c} ${d}`),m.setAttribute("class",`atfb-logicmap__path is-${e.action}${e.broken?" is-broken":""}`),m.dataset.from=e.from,m.dataset.to=e.to;const y=document.createElementNS(z,"title");y.textContent=e.label,m.append(y);const S=document.createElementNS(z,"circle");S.setAttribute("cx",String(c)),S.setAttribute("cy",String(d)),S.setAttribute("r","3.5"),S.setAttribute("class",`atfb-logicmap__dot is-${e.action}${e.broken?" is-broken":""}`),S.dataset.from=e.from,S.dataset.to=e.to,this.svg.append(m,S);const E=document.createElementNS(z,"text");E.setAttribute("x",String(i.width-Be)),E.setAttribute("y",String(d+3)),E.setAttribute("text-anchor","end"),E.setAttribute("class","atfb-logicmap__label"),E.dataset.from=e.from,E.dataset.to=e.to,E.textContent=e.short.length>Re?`${e.short.slice(0,Re-1)}…`:e.short;const R=document.createElementNS(z,"title");R.textContent=e.label,E.append(R),this.svg.append(E)}}const Dt={text:"text",email:"text",url:"text",tel:"text",number:"text",password:"text",date:"text",time:"text",datetime:"text",date_range:"composite",textarea:"textarea",select:"select",country:"select",multiselect:"options",radio:"options",checkboxes:"options",image_choice:"options",quiz:"options",switch:"toggle",consent:"toggle",file:"file",range:"range",rating:"summary",scale:"summary",likert:"summary",signature:"summary",repeater:"summary",color:"text",name:"composite",address:"composite",total:"text",hidden:"static",heading:"static",html:"static",divider:"static",spacer:"static",page_break:"static"};function Mt(n){return Dt[n]??"text"}function He(n,e){const t=/^choice:(\d+):(label|value)$/.exec(e);return String(t?n.choices?.[Number(t[1])]?.[t[2]]??"":n[e]??"")}function H(n){const{value:e,onInput:t,onCommit:s}=n,i=r("span",{class:`${n.class} atfb-editable`,text:e,attrs:{contenteditable:"plaintext-only",role:"textbox",spellcheck:"false","data-placeholder":n.placeholder}});return n.bind&&(i.dataset.atfbBind=n.bind),i.addEventListener("pointerdown",a=>a.stopPropagation()),i.addEventListener("input",()=>t(i.textContent??"")),i.addEventListener("keydown",a=>{a.key==="Enter"&&(a.preventDefault(),i.blur()),a.key==="Escape"&&(i.textContent=e,i.blur()),a.stopPropagation()}),i.addEventListener("blur",()=>s?.()),i}function Ot(n){const e=r("input",{class:"atf-choice__input",type:n==="checkboxes"||n==="multiselect"?"checkbox":"radio"});return e.disabled=!0,e}function Ft(n,e,t){const s=Mt(n.type),i=H({value:n.label,placeholder:"Write the question…",class:"atf-label",bind:"label",onInput:o=>t.edit(l=>{l.label=o}),onCommit:()=>t.restructure(()=>{})}),a=[s==="static"||s==="toggle"?null:i,It(n,e,s,t),qt(n,e,t)];return r("div",{class:"atfb-preview atf-form",children:[r("div",{class:`atf-field atf-field--${n.type}`,children:a})]})}function qt(n,e,t){if(!e?.supports.includes("hint"))return null;const s=H({value:n.hint??"",placeholder:"Add a hint…",class:"atf-hint",bind:"hint",onInput:i=>t.edit(a=>{a.hint=i})});return s.setAttribute("aria-label","Hint"),r("p",{class:"atfb-preview__hint",children:[s]})}function It(n,e,t,s){switch(t){case"text":return pe(n,"atf-input",s);case"textarea":return pe(n,"atf-input atf-textarea",s,!0);case"select":return r("div",{class:"atfb-preview__select",children:[pe(n,"atf-input atf-select",s)]});case"options":return Pt(n,s);case"toggle":return r("div",{class:n.type==="switch"?"atf-toggle atf-toggle--switch":"atf-toggle",children:[(()=>{const i=r("input",{class:"atf-toggle__input",type:"checkbox"});return i.disabled=!0,i})(),H({value:n.label||"",placeholder:n.type==="consent"?"What are they agreeing to?…":"What does this turn on?…",class:"atf-toggle__label",bind:"label",onInput:i=>s.edit(a=>{a.label=i}),onCommit:()=>s.restructure(()=>{})})]});case"file":return r("div",{class:"atf-file",children:[r("input",{class:"atf-file__input",type:"file"})]});case"range":return r("input",{class:"atf-range__input",type:"range"});case"composite":return r("div",{class:"atf-composite__parts",children:[r("span",{class:"atf-input atfb-preview__ghost",text:""}),r("span",{class:"atf-input atfb-preview__ghost",text:""})]});case"static":return Bt(n,e,s);default:return r("div",{class:"atfb-preview__stack",children:[r("p",{class:"atfb-preview__summary",text:`${e?.label??n.type} — set this up in the panel on the right.`}),n.type==="repeater"?Nt(n,s):null]})}}function pe(n,e,t,s=!1){const i=H({value:n.placeholder??"",placeholder:n.type==="select"||n.type==="country"?"Choose…":"Placeholder…",class:`${e} atfb-preview__box${s?" atfb-preview__box--tall":""}`,bind:"placeholder",onInput:a=>t.edit(o=>{o.placeholder=a})});return i.setAttribute("aria-label","Placeholder"),i}function fe(n,e,t,s,i,a){return r("span",{class:`atf-button ${t} atfb-preview__button`,children:[H({value:n,placeholder:e,class:"atfb-preview__button-text",bind:s,onInput:o=>i.edit(l=>a(l,o))})]})}function Nt(n,e){return fe(String(n.addLabel??""),"Add another","atf-button--secondary","addLabel",e,(t,s)=>{t.addLabel=s})}function Pt(n,e){const t=r("div",{class:"atf-choices__list"});return(n.choices??[]).forEach((s,i)=>{t.append(r("div",{class:"atf-choice atfb-preview__option",children:[Ot(n.type),H({value:s.label,placeholder:"Option…",class:"atf-choice__label",bind:`choice:${i}:label`,onInput:a=>{e.edit(o=>{const l=o.choices?.[i];if(!l)return;const c=!l.value||l.value===l.label;l.label=a,c&&(l.value=a)})}}),r("button",{class:"atfb-preview__remove",type:"button",title:"Remove this option",attrs:{"aria-label":`Remove ${s.label||"this option"}`},on:{pointerdown:a=>a.stopPropagation(),click:a=>{a.stopPropagation(),e.restructure(o=>{o.choices.splice(i,1)})}},children:[r("span",{text:"×"})]})]}))}),t.append(r("button",{class:"atfb-preview__add",type:"button",on:{pointerdown:s=>s.stopPropagation(),click:s=>{s.stopPropagation(),e.restructure(i=>{const a=i.choices.length+1;i.choices.push({label:`Option ${a}`,value:`Option ${a}`})})}},children:[r("span",{text:"+ Add option"})]})),r("fieldset",{class:"atf-choices",children:[t]})}function Bt(n,e,t){return n.type==="heading"?H({value:n.label,placeholder:"Section heading…",class:"atf-heading",bind:"label",onInput:s=>t.edit(i=>{i.label=s}),onCommit:()=>t.restructure(()=>{})}):n.type==="divider"?r("hr",{class:"atf-divider"}):n.type==="page_break"?r("div",{class:"atfb-preview__stack",children:[r("p",{class:"atfb-progress-name",children:[H({value:n.label,placeholder:"Name this step…",class:"atf-progress__label",bind:"label",onInput:s=>t.edit(i=>{i.label=s}),onCommit:()=>t.restructure(()=>{})})]}),r("p",{class:"atfb-preview__summary",text:"Everything after this is a new page."}),r("div",{class:"atf-nav atfb-preview__nav",children:[fe(String(n.prevLabel??""),"Back","atf-button--secondary","prevLabel",t,(s,i)=>{s.prevLabel=i}),fe(String(n.nextLabel??""),"Next","","nextLabel",t,(s,i)=>{s.nextLabel=i})]})]}):r("p",{class:"atfb-preview__summary",text:`${e?.label??n.type} — nothing is shown to the visitor here.`})}const me=new Map;function be(n){let e=me.get(n);return e||(e=C.mergeTags(n).catch(()=>[]),me.set(n,e)),e}function Rt(n){me.delete(n)}function Ht(n){const e=new Map;for(const t of n)for(const s of t.items)e.set(s.tag,s);return e}function jt(n,e){const t=Ht(e);return n.replace(/\{[a-z_]+(?::[^}]*)?\}/gi,s=>{const i=t.get(s.toLowerCase());return i?i.sample:s})}function Wt(n){return/\{[a-z_]+(?::[^}]*)?\}/i.test(n)}let j=null;function K(){j?.remove(),j=null}typeof document<"u"&&(document.addEventListener("pointerdown",n=>{j&&!j.contains(n.target)&&K()}),document.addEventListener("keydown",n=>{n.key==="Escape"&&j&&(K(),n.stopPropagation())}));function Ut(n,e){const t=n.selectionStart??n.value.length,s=n.selectionEnd??n.value.length;n.value=n.value.slice(0,t)+e+n.value.slice(s);const i=t+e.length;n.setSelectionRange(i,i),n.focus(),n.dispatchEvent(new Event("input",{bubbles:!0}))}function Yt(n){let e=n.parentElement;for(;e&&e!==document.body;){const t=getComputedStyle(e).overflowY;if(t==="auto"||t==="scroll"||t==="hidden")return e.getBoundingClientRect().bottom;e=e.parentElement}return window.innerHeight}function zt(n,e){const t=r("input",{class:"atfb-input atfb-tagpick__search",type:"search",placeholder:"Search values…",attrs:{"aria-label":"Search values"}}),s=r("div",{class:"atfb-tagpick__list"}),i=a=>{s.replaceChildren();const o=a.trim().toLowerCase();let l=0;for(const c of n){const d=c.items.filter(u=>!o||u.label.toLowerCase().includes(o)||u.tag.toLowerCase().includes(o));if(!d.length){c.empty&&!o&&!c.items.length&&s.append(r("p",{class:"atfb-tagpick__group",text:c.label}),r("p",{class:"atfb-tagpick__empty",text:c.empty}));continue}s.append(r("p",{class:"atfb-tagpick__group",text:c.label}));for(const u of d)l+=1,s.append(r("button",{class:"atfb-tagpick__item",type:"button",on:{click:()=>{e(u.tag),K()}},children:[r("span",{class:"atfb-tagpick__item-main",children:[r("span",{class:"atfb-tagpick__label",text:u.label}),r("code",{class:"atfb-tagpick__tag",text:u.tag})]}),u.hint||u.sample?r("span",{class:"atfb-tagpick__meta",text:u.sample?`e.g. ${u.sample}`:u.hint}):null]}))}!l&&o&&s.append(r("p",{class:"atfb-tagpick__empty",text:`Nothing matches “${a}”.`}))};return i(""),t.addEventListener("input",()=>i(t.value)),r("div",{class:"atfb-tagpick",attrs:{role:"dialog","aria-label":"Insert a value"},children:[r("p",{class:"atfb-tagpick__intro",text:"Pick something to drop in. It is filled in when the form is submitted."}),t,s]})}function je(n,e){const t=r("button",{class:"atfb-button atfb-button--ghost atfb-tagpick__open",type:"button",title:"Insert a value from the submission",children:[F("shortcode"),r("span",{text:"Insert a value"})]}),s=r("div",{class:"atfb-taggable",children:[n,r("div",{class:"atfb-taggable__tools",children:[t]})]}),i=e.preview===!1?null:r("p",{class:"atfb-taggable__preview"});i&&s.append(i);const a=()=>{if(i){if(!Wt(n.value)){i.textContent="",i.hidden=!0;return}be(e.formId).then(o=>{i.hidden=!1,i.replaceChildren(r("span",{class:"atfb-taggable__preview-label",text:"Reads as"}),r("span",{text:jt(n.value,o)}))})}};return n.addEventListener("input",a),a(),t.addEventListener("click",o=>{if(o.stopPropagation(),j&&s.contains(j)){K();return}K(),be(e.formId).then(l=>{const c=zt(l,d=>{Ut(n,d),a()});s.append(c),j=c,(window.innerHeight-c.getBoundingClientRect().topYt(s))&&c.classList.add("atfb-tagpick--above"),c.querySelector(".atfb-tagpick__search")?.focus()})}),s}function We(n,e){const t=parseFloat(String(n??""));return Number.isFinite(t)?t:e}function Ue(n,e){let t=e[0];for(const s of e)Math.abs(s.at-n)n.accent??"#2271b1",apply:n=>({accent:n,"accent-soft":`color-mix( in srgb, ${n} 12%, transparent )`,"border-focus":n,"focus-ring-color":n,"button-bg":n,"button-bg-hover":`color-mix( in srgb, ${n} 88%, #000 )`})},{id:"roundness",label:"Roundness",hint:"Corners, everywhere at once.",kind:"scale",steps:ge.map(({value:n,label:e})=>({value:n,label:e})),owns:["radius-field","radius-button","radius-card","radius-check"],read:n=>Ue(We(n["radius-field"],4),ge),apply:n=>{const e=ge.find(t=>t.value===n)?.at??4;return{"radius-field":`${e}px`,"radius-button":`${e}px`,"radius-card":`${Math.min(e*2,28)}px`,"radius-check":`${Math.min(e,6)}px`}}},{id:"density",label:"Density",hint:"How much air the form has.",kind:"scale",steps:ve.map(({value:n,label:e})=>({value:n,label:e})),owns:["pad-field-x","pad-field-y","gap-fields","gap-label","button-pad-x","button-pad-y"],read:n=>Ue(We(n["pad-field-y"],9),ve),apply:n=>{const e=ve.find(t=>t.value===n)?.at??9;return{"pad-field-y":`${e}px`,"pad-field-x":`${Math.round(e*1.4)}px`,"gap-fields":`${Math.round(e*2.2)}px`,"gap-label":`${Math.max(4,Math.round(e*.7))}px`,"button-pad-y":`${e+2}px`,"button-pad-x":`${Math.round(e*2.2)}px`}}},{id:"shadow",label:"Depth",hint:"How far the form sits off the page.",kind:"scale",steps:Xt,owns:["shadow-field","shadow-field-focus","shadow-button","shadow-button-hover","shadow-card"],read:n=>{const e=n["shadow-card"]??"none";return e==="none"||e.trim()===""?n["shadow-field"]&&n["shadow-field"]!=="none"?"hard":"none":e.includes("0 1px")||e.includes("2px")?"subtle":"lifted"},apply:n=>{switch(n){case"subtle":return{"shadow-field":"none","shadow-field-focus":"none","shadow-button":"0 1px 2px rgba( 0, 0, 0, 0.12 )","shadow-button-hover":"0 2px 4px rgba( 0, 0, 0, 0.16 )","shadow-card":"0 1px 3px rgba( 0, 0, 0, 0.1 )"};case"lifted":return{"shadow-field":"none","shadow-field-focus":"0 4px 12px rgba( 0, 0, 0, 0.12 )","shadow-button":"0 4px 10px rgba( 0, 0, 0, 0.16 )","shadow-button-hover":"0 8px 20px rgba( 0, 0, 0, 0.2 )","shadow-card":"0 10px 30px rgba( 0, 0, 0, 0.14 )"};case"hard":return{"shadow-field":"3px 3px 0 currentColor","shadow-field-focus":"5px 5px 0 currentColor","shadow-button":"4px 4px 0 currentColor","shadow-button-hover":"2px 2px 0 currentColor","shadow-card":"none"};default:return{"shadow-field":"none","shadow-field-focus":"none","shadow-button":"none","shadow-button-hover":"none","shadow-card":"none"}}}},{id:"field-style",label:"Fields",hint:"How an input is drawn.",kind:"choice",steps:Gt,owns:["field-style"],read:n=>n["field-style"]??"outline",apply:n=>({"field-style":n})},{id:"labels",label:"Labels",hint:"Where the question sits relative to the answer.",kind:"choice",steps:Jt,owns:["label-position"],read:n=>n["label-position"]??"top",apply:n=>({"label-position":n})}]}function Kt(n){return Ye().find(e=>e.owns.includes(n))??null}function ze(n){let e=n.activeSlug,t={...n.overrides},s=n.themes;const i=r("div",{class:"atfs-preview__frame"}),a=r("div",{class:"atfs-controls__body"}),o=r("div",{class:"atfs-quick"}),l=r("div",{class:"atfs-themes"}),c=De(async()=>{try{const h=await n.previewFor(e,t);i.innerHTML=h,document.dispatchEvent(new CustomEvent("atf-refresh"))}catch(h){D(i),i.append(r("p",{class:"atfb-error",text:h instanceof Error?h.message:"Preview failed."}))}},180),d=h=>t[h.token]!==void 0?t[h.token]:s.find(b=>b.slug===e)?.resolved?.[h.token]??h.default,u=()=>{D(l);for(const h of s){const f=r("button",{class:`atfs-theme${h.slug===e?" is-active":""}`,type:"button",attrs:{"aria-pressed":h.slug===e,title:h.description},children:[r("span",{class:"atfs-theme__chip",style:{background:h.resolved.surface??"#fff",borderColor:h.resolved.border??"#ccc",borderRadius:h.resolved["radius-field"]??"4px",boxShadow:h.resolved["shadow-card"]??"none"},children:[r("span",{class:"atfs-theme__accent",style:{background:h.resolved.accent??"#2271b1",borderRadius:h.resolved["radius-button"]??"4px"}})]}),r("span",{class:"atfs-theme__name",text:h.label}),h.custom?r("span",{class:"atfb-badge",text:"yours"}):null],on:{click:()=>{e=h.slug,t={},n.onTheme(e),u(),m(),y(),c(),A()}}});l.append(f)}},L=()=>{const f={...s.find(b=>b.slug===e)?.resolved??{}};for(const[b,g]of Object.entries(t))f[b]=g;return f},w=(h,f)=>{const b=h.apply(f,L());for(const[g,x]of Object.entries(b))t[g]=x,n.onOverride(g,x);m(),y(),c()},m=()=>{D(o);const h=L();for(const f of Ye()){const b=f.read(h);let g;if(f.kind==="colour"){const x=r("input",{class:"atfs-color",type:"color",value:ye(b),attrs:{"aria-label":f.label},on:{input:q=>w(f,q.target.value)}});g=r("div",{class:"atfs-color-row",children:[x,v(b,q=>w(f,q))]})}else g=r("div",{class:`atfs-segmented atfs-segmented--${f.kind}`,attrs:{role:"group","aria-label":f.label},children:(f.steps??[]).map(x=>r("button",{class:`atfs-segment${x.value===b?" is-on":""}`,type:"button",text:x.label,attrs:{"aria-pressed":x.value===b},on:{click:()=>w(f,x.value)}}))});o.append(r("div",{class:"atfs-dial",children:[r("div",{class:"atfs-dial__head",children:[r("span",{class:"atfs-dial__label",text:f.label}),r("span",{class:"atfs-dial__hint",text:f.hint})]}),g]}))}},y=()=>{D(a);const h=new Map;for(const b of n.tokens){const g=h.get(b.group)??[];g.push(b),h.set(b.group,g)}const f={colour:"Colour",shape:"Corners and borders",shadow:"Shadows",fields:"Field style",space:"Spacing",type:"Type",labels:"Labels",button:"Buttons",focus:"Focus ring",motion:"Motion"};for(const[b,g]of h)a.append(r("details",{class:"atfs-group",attrs:{open:b==="colour"},children:[r("summary",{text:f[b]??b}),...g.map(x=>S(x))]}))},S=h=>{const f=d(h),b=M=>{M===""?delete t[h.token]:t[h.token]=M,n.onOverride(h.token,M),c()};let g;if(h.control==="color"){const M=r("input",{class:"atfs-color",type:"color",value:ye(f),attrs:{"aria-label":`${h.label} colour`},on:{input:G=>{const Z=G.target.value;ae.value=Z,b(Z)}}}),ae=v(f,G=>{M.value=ye(G),b(G)});g=r("div",{class:"atfs-color-row",children:[M,ae]})}else if(h.control==="select")g=$(f,(h.options??[]).map(M=>({value:M,label:M})),b);else if(h.control==="length"){const M=parseFloat(f)||0,ae=h.unit??"px",G=h.token.includes("radius")?60:80,Z=r("input",{class:"atfs-range",type:"range",value:String(M),attrs:{min:"0",max:String(G),step:"1","aria-label":h.label},on:{input:re=>{const rt=`${re.target.value}${ae}`;at.value=rt,b(rt)}}}),at=v(f,re=>{Z.value=String(parseFloat(re)||0),b(re)});g=r("div",{class:"atfs-length-row",children:[Z,at]})}else g=v(f,b);const x=p(h.label,g),q=Kt(h.token);return q&&(x.classList.add("is-dialled"),x.append(r("span",{class:"atfs-owned",text:q.label}))),t[h.token]!==void 0&&(x.classList.add("is-overridden"),x.append(r("button",{class:"atfs-reset",type:"button",text:"Reset",attrs:{"aria-label":`Reset ${h.label} to the theme's value`},on:{click:()=>{b(""),y()}}}))),x},E=async()=>{const h=s.find(g=>g.slug===e),f=h?`${h.label} (mine)`:"My theme",b=window.prompt("Name this theme",f);if(b)try{const g={};for(const q of n.tokens){const M=d(q);M!==q.default&&(g[q.token]=M)}const x=await C.saveTheme({label:b,tokens:g});s=[...s.filter(q=>q.slug!==x.slug),x],n.onThemesChanged(s),e=x.slug,t={},n.onTheme(e),u(),y(),c(),I("Theme saved",x.label)}catch(g){I("Could not save the theme",g instanceof Error?g.message:"","error")}},R=async()=>{const h=s.find(f=>f.slug===e);if(h?.custom&&await he(`Delete “${h.label}”? Forms using it fall back to Clean.`,"Delete theme"))try{await C.deleteTheme(h.id),s=s.filter(f=>f.slug!==h.slug),n.onThemesChanged(s),e="clean",t={},n.onTheme(e),u(),y(),c()}catch(f){I("Could not delete the theme",f instanceof Error?f.message:"","error")}},X=async()=>{const h=s.find(g=>g.slug===e),f={label:h?.label??e,tokens:{...h?.tokens??{},...t}},b=JSON.stringify(f,null," ");try{await navigator.clipboard.writeText(b),I("Theme copied","Paste it into another site to import it.")}catch{window.prompt("Copy this theme",b)}},ie=()=>{const h=window.prompt("Paste a theme");if(h)try{const f=JSON.parse(h);if(!f.tokens||typeof f.tokens!="object")throw new Error("That does not look like a theme.");for(const[b,g]of Object.entries(f.tokens))t[b]=String(g),n.onOverride(b,String(g));y(),c()}catch(f){I("Could not read that theme",f instanceof Error?f.message:"","error")}},_=k("Delete",()=>void R(),"danger"),A=()=>{const h=s.find(f=>f.slug===e);_.hidden=!h?.custom};return u(),m(),y(),c(),A(),r("div",{class:"atf-studio",children:[r("div",{class:"atf-studio__top",children:[r("div",{class:"atf-studio__picker",children:[r("h2",{class:"atf-studio__heading",text:"Theme"}),l]}),r("div",{class:"atfs__actions",children:[k("Save as a theme",()=>void E(),"primary"),k("Export",()=>void X()),k("Import",ie),_]})]}),r("div",{class:"atf-studio__panes",children:[r("aside",{class:"atf-studio__controls",children:[r("p",{class:"atfb-hint",text:"Changes apply to this form only, until you save them as a theme."}),o,r("details",{class:"atfs-advanced",children:[r("summary",{text:"Every setting"}),r("p",{class:"atfb-hint",text:"The full token list. The dials above write into it."}),a]})]}),r("main",{class:"atf-studio__preview",children:[r("h2",{class:"screen-reader-text",text:"Live preview"}),i]})]})]})}async function we(){const n=document.querySelector("[data-atfs-root]");if(!n||n.dataset.atfsMounted)return;n.dataset.atfsMounted="1";const e=n.querySelector("[data-atfs-bar]"),t=n.querySelector(".atfs__body")??n;try{const[s,i,a]=await Promise.all([C.config(),C.listThemes(),C.listForms()]);if(!a.length){D(t),t.append(r("div",{class:"atfb-empty",children:[r("h2",{text:"No forms to preview"}),r("p",{text:"Make a form first — a theme needs something to dress."})]}));return}let o=a[0],l={},c=o.theme;const d=()=>{D(t),t.append(ze({themes:i,tokens:s.tokens,activeSlug:c,overrides:l,standalone:!0,onTheme:u=>{c=u},onOverride:(u,L)=>{L===""?delete l[u]:l[u]=L},previewFor:async(u,L)=>{const w=await C.getForm(o.id);w.schema.settings.theme=u,w.schema.settings.themeOverrides=L;const{html:m}=await C.preview(o.id,{schema:w.schema,theme:u});return m},onThemesChanged:u=>{i.length=0,i.push(...u)}}))};e&&(D(e),e.append(r("span",{class:"atfs__label",text:"Preview against"}),$(String(o.id),a.map(u=>({value:String(u.id),label:u.title||"(untitled)"})),u=>{o=a.find(L=>String(L.id)===u)??a[0],l={},d()}))),d()}catch(s){D(t),t.append(r("p",{class:"atfb-error",text:s instanceof Error?s.message:"Could not load themes."}))}}typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>void we()):we(),document.addEventListener("os-window-content-loaded",()=>void we()));function ye(n){const e=n.trim();return/^#[0-9a-f]{6}$/i.test(e)?e:/^#[0-9a-f]{3}$/i.test(e)?`#${e[1]}${e[1]}${e[2]}${e[2]}${e[3]}${e[3]}`:"#888888"}function xe(){return window.wp?.os??null}const Xe="allterrain-forms/preview",_e="allterrain-forms-preview";function Vt(n){const e=xe();if(!e?.registerTitleBarButton)return()=>{};const t=()=>{try{e.registerTitleBarButton({id:Xe,label:"Preview this form",icon:"dashicons-visibility",placement:"right",order:90,match:s=>{const i=s?.id??s?.config?.id??"";return i==="allterrain-forms"||i.startsWith("allterrain-forms#")},onClick:()=>void Ge(n),owner:"allterrain-forms-builder"})}catch{}};return e.ready?e.ready(t):t(),()=>{try{e.unregisterTitleBarButton?.(Xe)}catch{}}}async function Ge(n){n.isDirty()&&await n.save();const e=n.current();e&&Je(e.id,e.title,e.previewUrl)}function Je(n,e,t){const s=xe();if(!s?.windowManager?.open){window.open(t,"_blank","noopener");return}s.windowManager.open({id:`${_e}-${n}`,baseId:_e,url:t,title:`Preview: ${e}`,icon:"dashicons-visibility"})}function Qt(n,e,t){if(!xe()?.windowManager?.open||!document.querySelector(`[data-window-id^="${_e}-${n}"]`))return;const a=t.includes("?")?"&":"?";Je(n,e,`${t}${a}atf_r=${Date.now()}`)}const Zt="allterrain-forms/form";function es(){return window.wp?.os?.relations??null}function ts(n){const e=n.closest("[data-window-id], .os-window");if(!e)return null;const t=e.getAttribute("data-window-id");if(t)return t;const s=e.id??"";return s?s.replace(/^wp-window-/,""):null}const ss=6e3,Ke=120;function Ve(n,e){const t=es();if(ke.set(n,e),!t?.set)return;const s=a=>{if(ne.get(n)!==i)return;const o=ts(n);if(!o){Date.now()s(a),Ke);return}try{t.set(o,e)}catch(c){Qe||(Qe=!0,console.error("[AllTerrain Forms] The shell refused a window identity.",c,e)),ne.delete(n);return}if(!e||t.get?.(o)?.id===e.id||Date.now()>=a){ne.delete(n);return}window.setTimeout(()=>s(a),Ke)},i=Symbol("atf-identity");ne.set(n,i),s(Date.now()+ss)}let Qe=!1;const ne=new WeakMap,ke=new Map;function ns(){for(const[n,e]of ke){if(!n.isConnected){ke.delete(n);continue}Ve(n,e)}}if(typeof document<"u")for(const n of["os-window-content-loaded","os-window-opened"])document.addEventListener(n,()=>ns());function is(n,e){return{type:Zt,id:n.id,label:n.title||"Untitled form",related:[{id:`allterrain-forms/entries-${n.id}`,label:"Entries for this form",url:`${e}admin.php?page=allterrain-forms-entries&form=${n.id}`,group:"allterrain-forms",groupLabel:"Forms",icon:"dashicons-list-view"}]}}const V="allterrain-forms/field",as=["openstation/file","desktop-mode/file","openstation/attachment"],Ze="allterrain-forms/logic-map-v2";function B(n,e){return n.dataset.atfbBind=e,n}const et={level:{key:"level",label:"Heading level",control:"select",hint:"Headings should step down one at a time.",options:[{value:"2",label:"Heading 2"},{value:"3",label:"Heading 3"},{value:"4",label:"Heading 4"},{value:"5",label:"Heading 5"},{value:"6",label:"Heading 6"}]},content:{key:"content",label:"HTML",control:"textarea",hint:"Shown as written. Scripts are stripped when the form is saved."},consenttext:{key:"consentText",label:"What they are agreeing to",control:"textarea",hint:"Shown beside the tick box. Links are allowed."},height:{key:"height",label:"Height",control:"number",hint:"In pixels."},columns:{key:"columns",label:"Columns",control:"number",hint:"How many pictures sit side by side."},multiple:{key:"multiple",label:"Let them choose more than one",control:"checkbox"},inline:{key:"inline",label:"Lay the options out in a row",control:"checkbox"},other:{key:"other",label:"Offer an “Other” box",control:"checkbox",hint:"Adds a final option with a box to type in."},filetypes:{key:"filetypes",label:"Accepted file types",control:"commas",hint:"Extensions, separated by commas. Empty accepts anything the site allows."},maxsize:{key:"maxsize",label:"Largest file",control:"number",hint:"In megabytes."},maxfiles:{key:"maxfiles",label:"How many files",control:"number"},minrows:{key:"minRows",label:"Fewest rows",control:"number",also:{key:"maxRows",label:"Most rows"}},endlabels:{key:"minLabel",label:"Label at the low end",control:"text",also:{key:"maxLabel",label:"Label at the high end"}},points:{key:"points",label:"Points if correct",control:"number"},minchoices:{key:"minChoices",label:"Fewest they may pick",control:"number",also:{key:"maxChoices",label:"Most they may pick"}}},rs={label:"the canvas edits it in place, and the inspector mirrors it",placeholder:"edited by typing into the control on the canvas",hint:"edited under the control on the canvas",nextlabel:"edited on the page break’s own Next button",prevlabel:"edited on the page break’s own Back button",addlabel:"edited on the repeater’s own Add button",choices:"the choices editor, and the option list on the canvas",required:"the toggle on the card",width:"its own row",css:"its own row",prefill:"its own section",logic:"the conditional logic section",formula:"its own row, with the currency that goes with it",currency:"rendered with the formula",correct:"the choices editor marks the right answer",default:"its own row, typed to match what the field stores",rows:"a row count on a textarea; the statement list on a Likert matrix",parts:"a tick box per part, listed by the server",min:"the validation section, paired with max",max:"the validation section, paired with min",step:"the validation section",minlength:"the validation section, paired with maxlength",maxlength:"the validation section, paired with minlength",mindate:"the validation section, paired with maxdate",maxdate:"the validation section, paired with mindate",mintime:"the validation section, paired with maxtime",maxtime:"the validation section, paired with mintime",pattern:"the validation section",unique:"the validation section",maxchoices:"rendered with minchoices",maxrows:"rendered with minrows"};function tt(n,e,t){const s=n[e.key],i=a=>t(e.key,a);if(e.control==="checkbox")return T(e.label,!!s,i);if(e.control==="commas"){const a=Array.isArray(s)?s:[];return p(e.label,v(a.join(", "),o=>{i(o.split(",").map(l=>l.trim().replace(/^\./,"").toLowerCase()).filter(Boolean))}),e.hint)}return e.control==="select"?p(e.label,$(String(s??""),e.options??[],i),e.hint):e.control==="textarea"?p(e.label,te(String(s??""),i),e.hint):e.control==="number"?p(e.label,N(String(s??""),i),e.hint):p(e.label,B(v(String(s??""),i),e.key),e.hint)}function st(n,e){const t=new Set(n.map(i=>i.key).filter(Boolean));let s=n.length+1;return e.split(` -`).map(i=>i.trim()).filter(Boolean).map((i,a)=>{const o=n[a]?.key;if(o)return{key:o,label:i};for(;t.has(`r${s}`);)s+=1;return t.add(`r${s}`),{key:`r${s}`,label:i}})}const Se=[{value:"user:email",label:"Their email address",group:"About the person filling it in",tag:"{user:email}"},{value:"user:display_name",label:"Their name",group:"About the person filling it in",tag:"{user:display_name}"},{value:"user:first_name",label:"Their first name",group:"About the person filling it in"},{value:"user:last_name",label:"Their last name",group:"About the person filling it in"},{value:"user:login",label:"Their username",group:"About the person filling it in"},{value:"date:today",label:"Today’s date",group:"The date and time",tag:"{date}"},{value:"date:now",label:"The time right now",group:"The date and time",tag:"{time}"},{value:"site",label:"This site’s name",group:"About this site",tag:"{site}"},{value:"site:url",label:"This site’s address",group:"About this site",tag:"{site:url}"},{value:"site:admin_email",label:"The site administrator’s email",group:"About this site",tag:"{admin_email}"}],os=["About the person filling it in","The date and time","About this site"],Ee=(n,e)=>O?.i18n?.[n]??e;class nt{constructor(e){this.config=null,this.themes=[],this.forms=[],this.form=null,this.schema=null,this.selected=null,this.tab="build",this.logicMap=null,this.canvasTheme=r("style"),this.canvasThemeSignature="",this.openSections=new Map,this.logicMapOn=bt(Ze)!=="off",this.dirty=!1,this.teardowns=[],this.history=[],this.historyAt=-1,this.autosave=De(()=>{this.save(!0)},2500),this.renderInspector=mt(()=>{if(D(this.inspector),!this.schema||(this.root.classList.toggle("atfb--build-only-panes",this.tab!=="build"),this.tab!=="build"))return;const t=this.schema.fields.find(o=>o.id===this.selected);if(!t){this.inspector.append(r("div",{class:"atfb-placeholder",children:[r("p",{text:"Select a field to change it."}),r("p",{class:"atfb-hint",text:"Drag a field from the palette, or press one to add it to the end."})]}));return}const s=this.config?.fieldTypes.find(o=>o.type===t.type),i=s?.supports??[],a=(o,l)=>{t[o]=l,this.markDirty(),this.renderCanvas()};this.inspector.append(r("h3",{class:"atfb-inspector__title",text:s?.label??t.type}),r("p",{class:"atfb-hint",text:`Reference this field as {field:${t.id}}`})),i.includes("label")&&this.inspector.append(p("Label",B(v(t.label,o=>a("label",o)),"label"))),i.includes("placeholder")&&this.inspector.append(p("Placeholder",B(v(t.placeholder,o=>a("placeholder",o)),"placeholder"))),i.includes("hint")&&this.inspector.append(p("Hint",B(v(t.hint,o=>a("hint",o)),"hint"),"Shown under the field, and read out with it.")),i.includes("nextlabel")&&this.inspector.append(p("Next button",B(v(String(t.nextLabel??""),o=>a("nextLabel",o)),"nextLabel"),"Leave empty for “Next”.")),i.includes("prevlabel")&&this.inspector.append(p("Back button",B(v(String(t.prevLabel??""),o=>a("prevLabel",o)),"prevLabel"),"Leave empty for “Back”.")),i.includes("addlabel")&&this.inspector.append(p("Add button",B(v(String(t.addLabel??""),o=>a("addLabel",o)),"addLabel"),"Leave empty for “Add another”.")),i.includes("required")&&this.inspector.append(T("Required",t.required,o=>a("required",o))),i.includes("width")&&this.inspector.append(p("Width",$(t.width,[{value:"full",label:"Full width"},{value:"two-thirds",label:"Two thirds"},{value:"half",label:"Half"},{value:"third",label:"One third"},{value:"quarter",label:"One quarter"}],o=>a("width",o)))),s?.choices&&this.inspector.append(this.renderChoicesEditor(t,a)),this.renderTypeSettings(t,s,i,a),(t.type==="total"||i.includes("formula"))&&this.inspector.append(p("Formula",v(String(t.formula??""),o=>a("formula",o)),"Reference fields with braces: {f1} * {f2} + 10. Functions: min, max, sum, avg, round, ceil, floor, abs, sqrt, pow."),p("Currency symbol",v(String(t.currency??""),o=>a("currency",o)))),this.inspector.append(this.renderValidationSection(t,i,a)),this.inspector.append(this.renderLogicSection(t,a)),i.includes("prefill")&&this.inspector.append(this.prefillControl(t,a)),i.includes("css")&&this.inspector.append(p("CSS class",v(t.cssClass,o=>a("cssClass",o))))}),this.root=e,this.bar=e.querySelector("[data-atfb-bar]")??r("div"),this.palette=e.querySelector("[data-atfb-palette]")??r("div"),this.canvas=e.querySelector("[data-atfb-canvas]")??r("div"),this.inspector=e.querySelector("[data-atfb-inspector]")??r("div")}async start(){try{const[s,i,a]=await Promise.all([C.config(),C.listThemes(),C.listForms()]);this.config=s,this.themes=i,this.forms=a}catch(s){this.fail(s);return}this.teardowns.push(dt([V])),this.teardowns.push(Vt({current:()=>this.form?{id:this.form.id,title:this.form.title,previewUrl:this.form.previewUrl}:null,isDirty:()=>this.dirty,save:()=>this.save(!0)}));const e=s=>{this.dirty&&(s.preventDefault(),s.returnValue="")};window.addEventListener("beforeunload",e),this.teardowns.push(()=>window.removeEventListener("beforeunload",e));const t=s=>{!(s.metaKey||s.ctrlKey)||s.key.toLowerCase()!=="z"||s.target?.closest("input, textarea, [contenteditable]")||(s.preventDefault(),this.travel(s.shiftKey?1:-1))};this.root.addEventListener("keydown",t),this.teardowns.push(()=>this.root.removeEventListener("keydown",t)),this.renderBar(),this.renderPalette(),this.forms.length?await this.open(this.forms[0].id):this.renderFormsList()}destroy(){this.teardowns.forEach(e=>e()),this.teardowns=[]}fail(e){D(this.bar),this.bar.append(r("p",{class:"atfb-error",text:e instanceof Error?e.message:"Something went wrong loading your forms."}))}renderBar(){D(this.bar);const e=$(String(this.form?.id??""),[...this.forms.map(i=>({value:String(i.id),label:i.title||"(untitled)"}))],i=>void this.open(Number(i)));e.setAttribute("aria-label","Choose a form");const t=r("input",{class:"atfb-title",type:"text",value:this.form?.title??"",placeholder:"Untitled form",attrs:{"aria-label":"Form title"},on:{input:i=>{this.form&&(this.form.title=i.target.value,this.markDirty())}}}),s=r("div",{class:"atfb-tabs",attrs:{role:"tablist"},children:[["build","Build"],["theme","Theme"],["settings","Settings"],["notify","Notifications"],["confirm","Confirmations"]].map(([i,a])=>r("button",{class:`atfb-tab${this.tab===i?" is-active":""}`,type:"button",text:a,attrs:{role:"tab","aria-selected":this.tab===i},on:{click:()=>{this.tab=i,this.renderBar(),this.renderInspector(),this.renderCanvas()}}}))});this.bar.append(r("div",{class:"atfb-bar__left",children:[this.forms.length>1?e:null,t]}),s,r("div",{class:"atfb-bar__right",children:[this.historyButton("undo","Undo",-1),this.historyButton("redo","Redo",1),k("New",()=>void this.showTemplates(),"secondary","plus-alt2"),k("Export",()=>void this.exportForm(),"secondary","download"),k("Import",()=>void this.importForm(),"secondary","upload"),k("Preview",()=>void this.preview(),"secondary","visibility"),k("Entries",()=>this.openEntries(),"secondary","list-view"),this.logicMapButton(),this.saveButton()]}))}liveField(e){return this.schema?.fields.find(t=>t.id===e)}syncCanvas(e){const t=this.canvas.querySelector(`[data-atfb-card="${CSS.escape(e.id)}"]`);if(t)for(const s of t.querySelectorAll(".atfb-editable[data-atfb-bind]")){const i=He(e,s.dataset.atfbBind??"");s.textContent!==i&&(s.textContent=i)}}syncInspector(e){if(this.selected===e.id)for(const t of this.inspector.querySelectorAll("[data-atfb-bind]")){const s=He(e,t.dataset.atfbBind??"");t.value!==s&&("value"in t?t.value=s:t.setAttribute("value",s))}}rebindCanvas(){const e=document.activeElement;if(e instanceof HTMLElement&&this.canvas.contains(e)){e.addEventListener("blur",()=>this.rebindCanvas(),{once:!0});return}this.renderCanvas()}snapshot(){if(!this.schema)return;const e=JSON.stringify(this.schema);this.history[this.historyAt]!==e&&(this.history=this.history.slice(0,this.historyAt+1),this.history.push(e),this.history.length>60&&this.history.shift(),this.historyAt=this.history.length-1)}travel(e){const t=this.historyAt+e;!this.schema||t<0||t>=this.history.length||(this.historyAt=t,this.schema=JSON.parse(this.history[t]),this.schema.fields.some(s=>s.id===this.selected)||(this.selected=null),this.dirty=!0,this.autosave(),this.renderBar(),this.renderCanvas(),this.renderInspector())}historyButton(e,t,s){const i=this.historyAt+s,a=k(t,()=>this.travel(s),"secondary",e);return a.disabled=i<0||i>=this.history.length,a}exportForm(){if(!this.form||!this.schema)return;const e={plugin:"allterrain-forms",version:O?.version??"",title:this.form.title,schema:this.schema},t=new Blob([JSON.stringify(e,null," ")],{type:"application/json"}),s=URL.createObjectURL(t),i=r("a",{href:s,attrs:{download:`${this.form.title.replace(/[^a-z0-9]+/gi,"-").toLowerCase()||"form"}.json`}});document.body.append(i),i.click(),i.remove(),window.setTimeout(()=>URL.revokeObjectURL(s),1e3)}async importForm(){const e=r("input",{type:"file",attrs:{accept:"application/json,.json"}});e.addEventListener("change",async()=>{const t=e.files?.[0];if(t)try{const s=JSON.parse(await t.text());if(!s.schema)throw new Error("That file does not contain a form.");const i=await C.createForm({title:s.title??t.name.replace(/\.json$/i,""),schema:s.schema});this.forms.unshift({id:i.id,title:i.title,status:i.status,modified:i.modified,fields:i.schema.fields.length,theme:i.schema.settings.theme,entries:0,unread:0,views:0,submissions:0,shortcode:i.shortcode}),this.form=i,this.schema=i.schema,this.selected=null,this.dirty=!1,this.history=[],this.historyAt=-1,this.snapshot(),this.renderBar(),this.renderCanvas(),this.renderInspector(),I("Form imported",i.title)}catch(s){I("Could not import that file",s instanceof Error?s.message:"","error")}}),e.click()}saveButton(){const e=k("Save",()=>void this.save(),"primary");return e.disabled=!this.dirty,e.setAttribute("data-atfb-save",""),e}markDirty(){this.dirty=!0;const e=this.bar.querySelector("[data-atfb-save]");e&&(e.disabled=!1),this.autosave()}async save(e=!1){if(!(!this.form||!this.schema))try{const t=await C.updateForm(this.form.id,{title:this.form.title,schema:this.schema});this.form=t,this.schema=t.schema,this.dirty=!1,this.rebindCanvas(),Rt(t.id);const s=this.forms.find(a=>a.id===t.id);s&&(s.title=t.title);const i=this.bar.querySelector("[data-atfb-save]");i&&(i.disabled=!0),Qt(t.id,t.title,t.previewUrl),e||I("Form saved",t.title)}catch(t){I(Ee("saveFailed","Could not save"),t instanceof Error?t.message:"","error")}}async open(e){if(!(this.dirty&&!await he("You have unsaved changes. Discard them?"))){try{this.form=await C.getForm(e),this.schema=this.form.schema,this.selected=null,this.dirty=!1,this.history=[],this.historyAt=-1,this.snapshot()}catch(t){this.fail(t);return}this.renderBar(),this.renderCanvas(),this.renderInspector(),this.announceIdentity()}}announceIdentity(){this.form&&Ve(this.root,is(this.form,O?.adminUrl??""))}async showTemplates(){if(!this.config)return;const e=r("div",{class:"atfb-overlay"}),t=()=>e.remove(),s=r("div",{class:"atfb-templates",children:this.config.templates.map(i=>r("button",{class:"atfb-template",type:"button",on:{click:async()=>{t();try{const a=await C.createForm({template:i.slug});this.forms.unshift({id:a.id,title:a.title,status:a.status,modified:a.modified,fields:a.schema.fields.length,theme:a.schema.settings.theme,entries:0,unread:0,views:0,submissions:0,shortcode:a.shortcode}),this.form=a,this.schema=a.schema,this.selected=null,this.dirty=!1,this.renderBar(),this.renderCanvas(),this.renderInspector(),this.announceIdentity()}catch(a){I("Could not create the form",a instanceof Error?a.message:"","error")}}},children:[F(i.icon),r("strong",{text:i.label}),r("span",{text:i.description})]}))});e.append(r("div",{class:"atfb-modal",attrs:{role:"dialog","aria-label":"Start a new form"},children:[r("h2",{text:"Start a new form"}),s,r("div",{class:"atfb-modal__actions",children:[k("Cancel",t)]})]})),e.addEventListener("click",i=>{i.target===e&&t()}),document.addEventListener("keydown",i=>{i.key==="Escape"&&t()},{once:!0}),this.root.append(e),s.querySelector("button")?.focus()}renderFormsList(){D(this.canvas),this.canvas.append(r("div",{class:"atfb-empty",children:[r("h2",{text:"No forms yet"}),r("p",{text:"Start from a template, or build one from nothing."}),k("New form",()=>void this.showTemplates(),"primary","plus-alt2")]}))}openEntries(){const e=window.wp?.os;if(e?.openWindow){e.openWindow("allterrain-forms-entries");return}window.location.assign(`${O?.adminUrl??""}admin.php?page=allterrain-forms-entries`)}renderPalette(){if(!this.config)return;D(this.palette);const e=new Map;for(const s of this.config.fieldTypes){const i=e.get(s.group)??[];i.push(s),e.set(s.group,i)}const t=r("input",{class:"atfb-input atfb-palette__search",type:"search",placeholder:"Search fields",attrs:{"aria-label":"Search field types"},on:{input:s=>{const i=s.target.value.toLowerCase().trim();this.palette.querySelectorAll(".atfb-chip").forEach(a=>{const o=(a.textContent??"").toLowerCase();a.hidden=i!==""&&!o.includes(i)}),this.palette.querySelectorAll(".atfb-group").forEach(a=>{const o=Array.from(a.querySelectorAll(".atfb-chip")).some(l=>!l.hidden);a.hidden=!o})}}});this.palette.append(t);for(const[s,i]of Object.entries(this.config.groups)){const a=e.get(s);a?.length&&this.palette.append(r("div",{class:"atfb-group",children:[r("h3",{class:"atfb-group__title",text:i}),r("div",{class:"atfb-group__items",children:a.map(o=>this.paletteChip(o))})]}))}}paletteChip(e){const t=r("button",{class:"atfb-chip",type:"button",title:e.description,attrs:{"data-atf-type":e.type},children:[F(e.icon),r("span",{text:e.label})]});return t.addEventListener("pointerdown",s=>{const i=r("div",{class:"atfb-chip atfb-chip--ghost",children:[F(e.icon),r("span",{text:e.label})]});U().start({payload:Te(V,t,{fieldType:e.type,isNew:!0},s,i),origin:s,onClickOnly:()=>this.addField(e.type)})}),t.addEventListener("click",s=>{U().recentlyEndedDrag()&&s.preventDefault()}),t}renderCanvas(){if(D(this.canvas),!this.schema||!this.form){this.renderFormsList();return}if(this.tab!=="build"){this.canvas.append(this.renderTabCanvas());return}const e=r("div",{class:"atfb-canvas__list",attrs:{"data-atfb-list":""}});this.schema.fields.length||e.append(r("div",{class:"atfb-placeholder",text:Ee("emptyCanvas","Drag a field from the left to begin.")})),this.schema.fields.forEach((s,i)=>{e.append(this.renderFieldCard(s,i))});const t=r("div",{class:"atfb-canvas__inner",children:[r("p",{class:"atfb-shortcode",text:this.form.shortcode,title:"Paste this anywhere to place the form"}),e]});this.canvas.append(t),this.registerCanvasTarget(e),this.paintLogicMap(t),this.paintCanvasTheme()}prefillControl(e,t){const s=e.prefill.startsWith("query:"),i=Se.some(w=>w.value===e.prefill),a=s?"query":i&&e.prefill||(e.prefill?"custom":""),o=r("div",{class:"atfb-prefill__detail"}),l=r("p",{class:"atfb-row__hint atfb-prefill__preview"}),c=w=>{if(o.replaceChildren(),l.replaceChildren(),w==="query"){const m=e.prefill.startsWith("query:")?e.prefill.slice(6):"";o.append(v(m,y=>{const S=y.trim();t("prefill",S?`query:${S}`:""),d(`query:${S}`)},"utm_source"),r("p",{class:"atfb-row__hint",text:"The name of the parameter in the link people arrive on."}))}w==="custom"&&o.append(v(e.prefill,m=>{t("prefill",m),d(m)},"myplugin:something"),r("p",{class:"atfb-row__hint",text:"For a source another plugin has added through atf_resolve_prefill."})),d(e.prefill)},d=w=>{if(l.replaceChildren(),!w)return;if(w.startsWith("query:")){const y=w.slice(6);y&&(l.textContent=`A visit to …/your-page/?${y}=abc opens the form with “abc” in it.`);return}const m=Se.find(y=>y.value===w)?.tag;m&&be(this.form?.id??0).then(y=>{for(const S of y)for(const E of S.items)if(E.tag===m){const R=w.startsWith("user:");if(!E.sample){l.textContent="Empty unless the visitor is signed in.";return}l.textContent=R?`Opens with “${E.sample}” for you — empty for a visitor who is not signed in.`:`Opens with “${E.sample}”.`;return}})};c(a);const u=r("select",{class:"atfb-input atfb-select",on:{change:w=>{const m=w.target.value;t("prefill",m==="query"||m==="custom"?"":m),c(m)}}});u.append(r("option",{value:"",text:"Nothing — leave it empty",attrs:{selected:a===""}}));for(const w of os){const m=document.createElement("optgroup");m.label=w;for(const y of Se.filter(S=>S.group===w))m.append(r("option",{value:y.value,text:y.label,attrs:{selected:y.value===a}}));u.append(m)}const L=document.createElement("optgroup");return L.label="From the link they arrived on",L.append(r("option",{value:"query",text:"A parameter in the web address",attrs:{selected:a==="query"}})),u.append(L,r("option",{value:"custom",text:"Something else (advanced)",attrs:{selected:a==="custom"}})),p("Pre-fill this with",r("div",{class:"atfb-prefill",children:[u,o,l]}),"What the box already contains when the form opens. They can still change it.")}renderCondition(e){const t=e.some(s=>s.kind==="field"&&s.missing);return r("span",{class:`atfb-cond${t?" is-broken":""}`,attrs:{"aria-label":Pe(e)},children:[F("randomize"),...e.map(s=>this.renderConditionToken(s))]})}renderConditionToken(e){return e.kind==="field"&&!e.missing?r("button",{class:"atfb-cond__chip atfb-cond__chip--field",type:"button",text:e.text,title:"Go to this question",on:{click:i=>{i.stopPropagation(),this.selectField(e.fieldId),this.canvas.querySelector(`[data-atfb-card="${CSS.escape(e.fieldId)}"]`)?.scrollIntoView({block:"nearest",behavior:"smooth"})}}}):r("span",{class:{verb:"atfb-cond__verb",field:"atfb-cond__chip atfb-cond__chip--missing",operator:"atfb-cond__op",value:"atfb-cond__chip atfb-cond__chip--value",join:"atfb-cond__join"}[e.kind],text:e.text})}section(e,t,s,i=!1){const a=r("details",{class:"atfb-section",attrs:{open:this.openSections.get(e)??i},children:[r("summary",{text:t}),...s]});return a.addEventListener("toggle",()=>this.openSections.set(e,a.open)),a}logicMapButton(){const e=ue(this.schema?.fields??[]).length>0,t=k(this.logicMapOn?"Hide logic":"Show logic",()=>{this.logicMapOn=!this.logicMapOn,gt(Ze,this.logicMapOn?"on":"off"),this.renderBar(),this.renderCanvas()},this.logicMapOn?"primary":"secondary","randomize");return t.title="Draw a line from each question to the ones it decides.",t.hidden=!e,t}paintLogicMap(e){this.logicMap?.destroy(),this.logicMap=null;const t=this.schema?.fields??[],s=ue(t);if(!s.length||!this.logicMapOn)return;e.classList.add("has-logicmap");const i=new At(e);i.setEdges(s),i.highlight(this.selected??""),this.logicMap=i,e.addEventListener("pointerover",a=>{const o=a.target.closest("[data-atfb-card]");i.highlight(o?.dataset.atfbCard??this.selected??"")}),e.addEventListener("pointerleave",()=>i.highlight(this.selected??""))}renderFieldCard(e,t){const s=this.config?.fieldTypes.find(d=>d.type===e.type),i=this.selected===e.id,a=this.schema?.fields??[],o=Ct(e,a),l=$t(a).get(e.id)??0,c=r("div",{class:`atfb-card${i?" is-selected":""}`,attrs:{"data-atfb-card":e.id,"data-index":t,tabindex:"0",role:"button","aria-pressed":i,"aria-label":`${e.label||s?.label||e.type}, ${t+1} of ${this.schema?.fields.length??0}`},children:[r("div",{class:"atfb-card__grip",attrs:{"aria-hidden":"true"},children:[F("menu")]}),r("div",{class:"atfb-card__body",children:[r("div",{class:"atfb-card__head",children:[F(s?.icon??"dashicons-forms"),r("span",{class:"atfb-card__type",text:s?.label??e.type}),this.requiredToggle(e),l?r("span",{class:"atfb-badge atfb-badge--controls",text:l===1?"controls 1 field":`controls ${l} fields`,title:"Other questions appear or disappear based on this answer."}):null]}),Ft(e,s,{edit:d=>{const u=this.liveField(e.id);u&&(d(u),this.markDirty(),this.syncInspector(u))},restructure:d=>{const u=this.liveField(e.id);u&&(this.snapshot(),d(u),this.markDirty(),this.renderCanvas(),this.renderInspector())}}),o.length?this.renderCondition(o):null]}),r("div",{class:"atfb-card__actions",children:[this.cardAction("admin-page","Duplicate",()=>this.duplicateField(e.id)),this.cardAction("trash","Delete",()=>void this.deleteField(e.id))]})]});return c.addEventListener("click",d=>{d.target.closest(".atfb-card__actions")||U().recentlyEndedDrag()||this.selectField(e.id)}),c.addEventListener("keydown",d=>{if(d.key==="Enter"||d.key===" "){d.preventDefault(),this.selectField(e.id);return}d.altKey&&(d.key==="ArrowUp"||d.key==="ArrowDown")&&(d.preventDefault(),this.moveField(e.id,d.key==="ArrowUp"?t-1:t+1),window.requestAnimationFrame(()=>{this.canvas.querySelector(`[data-atfb-card="${CSS.escape(e.id)}"]`)?.focus()})),(d.key==="Delete"||d.key==="Backspace")&&(d.preventDefault(),this.deleteField(e.id))}),c.addEventListener("pointerdown",d=>{d.target.closest(".atfb-card__actions")||U().start({payload:Te(V,c,{fieldId:e.id,field:e,isNew:!1},d),origin:d})}),c}requiredToggle(e){return r("button",{class:`atfb-req${e.required?" is-on":""}`,type:"button",text:e.required?"Required":"Optional",title:e.required?"This must be answered. Click to make it optional.":"Click to make this required.",attrs:{"aria-pressed":e.required?"true":"false"},on:{pointerdown:s=>s.stopPropagation(),click:s=>{s.stopPropagation(),e.required=!e.required,this.markDirty(),this.renderCanvas(),this.renderInspector()}}})}cardAction(e,t,s){return r("button",{class:"atfb-card__action",type:"button",title:t,attrs:{"aria-label":t},on:{click:i=>{i.stopPropagation(),s()}},children:[F(e)]})}registerCanvasTarget(e){const t=r("div",{class:"atfb-marker",attrs:{"aria-hidden":"true"}}),s=U().registerDropTarget({id:`atfb-canvas-${this.form?.id??0}`,element:e,accept:a=>a.type===V,onEnter:()=>e.classList.add("is-dropping"),onLeave:()=>{e.classList.remove("is-dropping"),t.remove()},onDrop:(a,o)=>{e.classList.remove("is-dropping"),t.remove();const l=a.payload.data,c=l.fieldId?this.canvas.querySelector(`[data-atfb-card="${CSS.escape(l.fieldId)}"]`):null,d=$e(e,".atfb-card",o.clientY,c??void 0);if(l.isNew&&l.fieldType){this.addField(l.fieldType,d);return}if(l.fieldId&&this.schema?.fields.some(u=>u.id===l.fieldId)){this.moveField(l.fieldId,d);return}l.field&&this.insertField({...l.field,id:""},d)}});this.teardowns.push(s);const i=a=>{const o=a.detail;if(o?.payload?.type!==V||!e.classList.contains("is-dropping"))return;const l=o.clientY??0,c=$e(e,".atfb-card",l),d=e.querySelectorAll(".atfb-card");c>=d.length?e.append(t):d[c].before(t)};document.addEventListener("os.drag.move",i),this.teardowns.push(()=>document.removeEventListener("os.drag.move",i))}addField(e,t){const s=this.config?.fieldTypes.find(a=>a.type===e);if(!s||!this.schema)return;const i={id:this.nextFieldId(),type:e,label:s.input?s.label:"",placeholder:"",hint:"",required:!1,width:"full",cssClass:"",default:"",choices:s.choices?[{label:"First choice",value:"first"},{label:"Second choice",value:"second"}]:[],logic:{enabled:!1,action:"show",match:"all",rules:[]},messages:{},prefill:"",...s.settings};this.insertField(i,t)}insertField(e,t){if(!this.schema)return;e.id||(e.id=this.nextFieldId()),this.snapshot();const s=t===void 0?this.schema.fields.length:Math.max(0,Math.min(t,this.schema.fields.length));this.schema.fields.splice(s,0,e),this.selected=e.id,this.markDirty(),this.renderCanvas(),this.renderInspector(),window.requestAnimationFrame(()=>{this.canvas.querySelector(`[data-atfb-card="${CSS.escape(e.id)}"]`)?.focus()})}moveField(e,t){if(!this.schema)return;const s=this.schema.fields.findIndex(o=>o.id===e);if(s<0)return;const i=Math.max(0,Math.min(t,this.schema.fields.length-1));if(s===i)return;this.snapshot();const[a]=this.schema.fields.splice(s,1);this.schema.fields.splice(i>s?i-1:i,0,a),this.markDirty(),this.renderCanvas()}duplicateField(e){if(!this.schema)return;const t=this.schema.fields.findIndex(i=>i.id===e);if(t<0)return;const s=JSON.parse(JSON.stringify(this.schema.fields[t]));s.id=this.nextFieldId(),this.insertField(s,t+1)}async deleteField(e){if(!this.schema)return;const t=this.schema.fields.filter(i=>i.logic?.rules?.some(a=>a.field===e)),s=t.length?`Delete this field? ${t.length} other field${t.length===1?"":"s"} use it in a condition, and those conditions will stop working.`:Ee("confirmDelete","Delete this? It cannot be undone.");await he(s,"Delete field")&&(this.snapshot(),this.schema.fields=this.schema.fields.filter(i=>i.id!==e),this.selected===e&&(this.selected=null),this.markDirty(),this.renderCanvas(),this.renderInspector())}selectField(e){this.selected=e;for(const t of this.canvas.querySelectorAll("[data-atfb-card]")){const s=t.dataset.atfbCard===e;t.classList.toggle("is-selected",s),t.setAttribute("aria-pressed",s?"true":"false")}this.logicMap?.highlight(e),this.renderInspector()}nextFieldId(){const e=new Set((this.schema?.fields??[]).map(s=>s.id));let t=e.size+1;for(;e.has(`f${t}`);)t++;return`f${t}`}renderTypeSettings(e,t,s,i){for(const a of s){const o=et[a];o&&(this.inspector.append(tt(e,o,i)),o.also&&this.inspector.append(tt(e,{...o,key:o.also.key,label:o.also.label,hint:o.also.hint},i)))}s.includes("rows")&&e.type!=="likert"&&this.inspector.append(p("Lines tall",N(String(e.rows??""),a=>i("rows",a)))),s.includes("rows")&&e.type==="likert"&&this.inspector.append(this.renderStatementList(e,i)),s.includes("parts")&&this.inspector.append(this.renderPartsEditor(e,t,i)),s.includes("default")&&!["files","object"].includes(t?.value??"")&&this.inspector.append(this.renderDefaultControl(e,t,i))}renderStatementList(e,t){const s=Array.isArray(e.rows)?e.rows:[];return p("Statements",te(s.map(i=>i.label??"").join(` -`),i=>t("rows",st(s,i)),5),"One per line. Each becomes a row of the matrix.")}renderPartsEditor(e,t,s){const i=t?.parts??[],a=Array.isArray(e.parts)?e.parts:i.map(l=>l.key),o=i.map(l=>T(l.label,a.includes(l.key),c=>{const d=i.map(u=>u.key).filter(u=>u===l.key?c:a.includes(u));s("parts",d.length?d:[l.key])}));return r("div",{class:"atfb-section",children:[r("h4",{class:"atfb-section__title",text:"Parts to ask for"}),...o]})}renderDefaultControl(e,t,s){const i="Filled in before they start. They can change it.";return e.type==="switch"||e.type==="consent"?T("On by default",!!e.default,a=>s("default",a)):t?.choices&&(e.choices??[]).length?p("Default answer",$(String(e.default??""),[{value:"",label:"Nothing chosen"},...(e.choices??[]).map(a=>({value:String(a.value),label:a.label||String(a.value)}))],a=>s("default",a)),i):p("Default answer",v(String(e.default??""),a=>s("default",a)),i)}renderChoicesEditor(e,t){const s=e.choices??[],i=r("div",{class:"atfb-choices"});return s.forEach((a,o)=>{const l=r("div",{class:"atfb-choice-row",children:[e.type==="image_choice"?this.choiceImageWell(a,o,e):null,B(v(a.label,c=>{const d=!a.value||a.value===a.label;a.label=c,d&&(a.value=c),this.markDirty(),this.syncCanvas(e)}),`choice:${o}:label`),B(v(a.value,c=>{a.value=c,this.markDirty()},"value"),`choice:${o}:value`),e.type==="quiz"||a.points!==void 0?N(String(a.points??""),c=>{a.points=c===""?void 0:Number(c),this.markDirty()}):N(String(a.price??""),c=>{a.price=c===""?void 0:Number(c),this.markDirty()}),r("button",{class:"atfb-card__action",type:"button",attrs:{"aria-label":`Remove ${a.label}`},on:{click:()=>{s.splice(o,1),t("choices",s),this.renderInspector()}},children:[F("trash")]})]});i.append(l)}),r("div",{class:"atfb-section",children:[r("h4",{text:"Choices"}),r("p",{class:"atfb-hint",text:e.type==="quiz"?"Label, value, points.":"Label, value, and a price for calculations."}),i,k("Add choice",()=>{s.push({label:"",value:""}),t("choices",s),this.renderInspector()},"ghost","plus-alt2"),e.type==="quiz"?p("Correct answer",$(String(e.correct??""),[{value:"",label:"—"},...s.map(a=>({value:a.value,label:a.label}))],a=>t("correct",a))):null]})}choiceImageWell(e,t,s){const i=r("div",{class:`atfb-well${e.image?" has-image":""}`,attrs:{"data-choice":t,"aria-label":`Image for ${e.label||`choice ${t+1}`}`},children:[e.image?r("span",{class:"atfb-well__id",text:`#${e.image}`}):F("format-image")]}),a=U().registerDropTarget({id:`atfb-well-${s.id}-${t}`,element:i,accept:o=>as.includes(o.type),onEnter:()=>i.classList.add("is-dropping"),onLeave:()=>i.classList.remove("is-dropping"),onDrop:o=>{i.classList.remove("is-dropping");const l=o.payload.data,c=Number(l.attachmentId??l.id??l.file?.id??0);if(!c){I("That is not an image this field can use","","error");return}e.image=c,this.markDirty(),this.renderInspector()}});return this.teardowns.push(a),i.addEventListener("click",()=>{e.image&&(e.image=void 0,this.markDirty(),this.renderInspector())}),i}renderValidationSection(e,t,s){const i=[],a=[];t.includes("minlength")&&a.push(["minlength","Minimum characters"],["maxlength","Maximum characters"]),t.includes("min")?a.push(["min","Minimum"],["max","Maximum"]):t.includes("max")&&a.push(["max","Highest"]),t.includes("step")&&a.push(["step","Steps of"]),t.includes("mindate")&&a.push(["minDate","Earliest date"],["maxDate","Latest date"]),t.includes("mintime")&&a.push(["minTime","Earliest time"],["maxTime","Latest time"]);for(const[l,c]of a)i.push(p(c,v(String(e[l]??""),d=>s(l,d))));t.includes("pattern")&&i.push(p("Pattern",v(String(e.pattern??""),l=>s("pattern",l)),"A regular expression, without slashes.")),t.includes("unique")&&i.push(T("No two people may submit the same value",!!e.unique,l=>s("unique",l)));const o=e.messages??{};return t.includes("required")&&i.push(p("Message when required",v(o.required??"",l=>{o.required=l,s("messages",o)}),"Leave empty for the default wording.")),i.length?this.section(`validation:${e.id}`,"Validation",i):r("div",{class:"atfb-section is-empty"})}renderLogicSection(e,t){const s=e.logic,i=(this.schema?.fields??[]).filter(o=>o.id!==e.id&&o.type!=="page_break"),a=r("div",{class:"atfb-rules"});return s.rules.forEach((o,l)=>{a.append(r("div",{class:"atfb-rule",children:[$(o.field,i.map(c=>({value:c.id,label:c.label||c.id})),c=>{o.field=c,this.markDirty()}),$(o.operator,Object.entries(this.config?.operators??{}).map(([c,d])=>({value:c,label:d})),c=>{o.operator=c,this.markDirty()}),v(o.value,c=>{o.value=c,this.markDirty()}),r("button",{class:"atfb-card__action",type:"button",attrs:{"aria-label":"Remove this rule"},on:{click:()=>{s.rules.splice(l,1),t("logic",s),this.renderInspector()}},children:[F("trash")]})]}))}),this.section(`logic:${e.id}`,"Conditional logic",[T("Only show this field sometimes",s.enabled,o=>{s.enabled=o,t("logic",s),this.renderInspector()}),s.enabled?r("div",{children:[r("div",{class:"atfb-rule-head",children:[$(s.action,[{value:"show",label:"Show"},{value:"hide",label:"Hide"}],o=>{s.action=o,t("logic",s)}),r("span",{text:"this field when"}),$(s.match,[{value:"all",label:"all"},{value:"any",label:"any"}],o=>{s.match=o,t("logic",s)}),r("span",{text:"of these match:"})]}),a,k("Add rule",()=>{s.rules.push({field:i[0]?.id??"",operator:"is",value:""}),t("logic",s),this.renderInspector()},"ghost","plus-alt2")]}):null],s.enabled)}renderTabCanvas(){return this.schema?this.tab==="theme"?ze({themes:this.themes,tokens:this.config?.tokens??[],activeSlug:this.schema.settings.theme,overrides:this.schema.settings.themeOverrides,onTheme:e=>{this.schema.settings.theme=e,this.markDirty()},onOverride:(e,t)=>{t===""?delete this.schema.settings.themeOverrides[e]:this.schema.settings.themeOverrides[e]=t,this.markDirty()},previewFor:(e,t)=>this.previewHtml(e,t),onThemesChanged:e=>{this.themes=e}}):this.tab==="settings"?this.renderSettingsPane():this.tab==="notify"?this.renderNotificationsPane():this.renderConfirmationsPane():r("div")}async paintCanvasTheme(){if(!this.form||!this.schema)return;const e=this.schema.settings.theme,t=JSON.stringify([e,this.schema.settings.themeOverrides]);if(t!==this.canvasThemeSignature){this.canvasThemeSignature=t;try{const s=await this.previewHtml(e,this.schema.settings.themeOverrides??{}),i=/