<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>VerseDocs Licensing</title>
<style>
  /* See scripts/check_webresource_css.py and tests/browser/webresource.spec.js: the HTML
     `hidden` attribute only hides an element because the browser's own stylesheet says
     [hidden] { display: none }. Any author rule that sets display on the same element beats
     it. This guard must stay first, same as templatestudio.htm and configuration.htm. */
  [hidden] { display: none !important; }

  :root {
    --navy: #1E3A5F;
    --navy-dark: #16293F;
    --navy-tint: #EAF0F6;
    --ink: #1B1E24;
    --ink-soft: #5B6472;
    --border: #E1E5EA;
    --surface: #FFFFFF;
    --bg: #F5F7FA;
    --good: #157F3C;
    --good-bg: #E6F6EB;
    --bad: #B3261E;
    --bad-bg: #FCEAE9;
    --radius: 8px;
    --shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 1px 3px rgba(16, 24, 40, 0.08);
  }
  * { box-sizing: border-box; }
  html, body { height: 100%; }
  body {
    margin: 0;
    font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, Arial, sans-serif;
    color: var(--ink);
    background: var(--bg);
    font-size: 13px;
    line-height: 1.45;
  }
  button { font-family: inherit; }
  h1, h2 { margin: 0; font-weight: 600; }

  .app { display: flex; flex-direction: column; height: 100vh; min-height: 480px; }
  .topbar {
    background: var(--navy); color: #fff; padding: 10px 18px;
    display: flex; align-items: center; gap: 10px; flex: none;
    box-shadow: var(--shadow);
  }
  .brand { display: flex; align-items: center; gap: 10px; font-size: 15px; font-weight: 600; letter-spacing: .1px; }
  .brand svg { width: 20px; height: 20px; fill: #fff; }
  .brand .sub { font-weight: 400; opacity: .8; font-size: 12px; margin-left: 4px; }

  .btn {
    appearance: none; border: 1px solid transparent; border-radius: 6px; padding: 7px 13px;
    font-size: 12.5px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 6px;
    transition: background .12s ease, border-color .12s ease, color .12s ease;
  }
  .btn-solid { background: var(--navy); color: #fff; }
  .btn-solid:hover { background: var(--navy-dark); }
  .btn-solid:disabled { opacity: .45; cursor: default; }
  .btn-outline { background: #fff; color: var(--ink); border-color: var(--border); }
  .btn-outline:hover { background: #F2F4F7; }
  .btn-sm { padding: 4px 9px; font-size: 11.5px; }

  .body { flex: 1; overflow-y: auto; padding: 22px 26px; max-width: 720px; margin: 0 auto; width: 100%; }
  .page-title { font-size: 18px; margin-bottom: 4px; }
  .page-sub { color: var(--ink-soft); font-size: 12.6px; margin-bottom: 18px; }

  .card {
    background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
    padding: 16px 20px; margin-bottom: 18px; box-shadow: var(--shadow);
  }
  .card h2 { font-size: 14px; margin-bottom: 4px; }
  .card .card-sub { color: var(--ink-soft); font-size: 12.4px; margin-bottom: 14px; }

  .status-row { display: flex; align-items: center; justify-content: space-between; padding: 7px 0; border-bottom: 1px solid var(--border); }
  .status-row:last-child { border-bottom: none; }
  .status-label { color: var(--ink-soft); font-size: 12.4px; }
  .status-value { font-family: Consolas, "Courier New", monospace; font-size: 12.6px; font-weight: 600; }
  .idvalue { display: flex; align-items: center; gap: 8px; }
  .status-hint { color: var(--ink-soft); font-size: 11.6px; margin-top: 10px; }

  .badge { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 600; }
  .badge.licensed { background: var(--good-bg); color: var(--good); }
  .badge.trial { background: var(--bad-bg); color: var(--bad); }

  .field { margin-bottom: 12px; }
  .field label { display: block; font-size: 12.4px; font-weight: 600; margin-bottom: 4px; }
  .field input[type=text], .field input[type=email], .field textarea {
    width: 100%; padding: 7px 9px; border: 1px solid var(--border); border-radius: 6px;
    font-size: 12.5px; font-family: inherit; color: var(--ink);
  }
  .field textarea { min-height: 84px; resize: vertical; }
  .field .hint { color: var(--ink-soft); font-size: 11.6px; margin-top: 4px; }

  .toggle-row { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 10px; }
  .toggle-row input[type=checkbox] { width: 16px; height: 16px; margin-top: 2px; flex: none; cursor: pointer; }
  .toggle-row label { font-weight: 600; cursor: pointer; }

  .disclosure { color: var(--ink-soft); font-size: 12px; line-height: 1.55; margin: 0 0 14px 26px; }

  .actions { display: flex; align-items: center; gap: 12px; margin-top: 4px; }
  .status-line { font-size: 12.2px; }
  .status-line.ok { color: var(--good); }
  .status-line.err { color: var(--bad); }

  .note-banner {
    background: var(--navy-tint); color: var(--navy); border-radius: var(--radius);
    padding: 10px 14px; font-size: 12.4px; margin: 0 0 14px;
  }
  .error-banner { background: var(--bad-bg); border: 1px solid #F0C6C3; color: var(--bad); border-radius: var(--radius); padding: 12px 16px; font-size: 12.6px; }
  .fatal-error { max-width: 560px; margin: 60px auto; }
  .loading-state { padding: 18px; color: var(--ink-soft); font-size: 12.6px; text-align: center; }
  .spinner { display: inline-block; width: 13px; height: 13px; border: 2px solid #D8DEE6; border-top-color: var(--navy); border-radius: 50%; animation: spin .7s linear infinite; vertical-align: -2px; margin-right: 6px; }
  @keyframes spin { to { transform: rotate(360deg); } }

  .plans { display: flex; gap: 14px; margin-bottom: 12px; flex-wrap: wrap; }
  .plan { flex: 1 1 220px; border: 1px solid var(--border); border-radius: 6px; padding: 14px 16px; display: flex; flex-direction: column; }
  .plan h3 { font-size: 13.5px; margin: 0 0 8px; }
  .plan-features { list-style: none; margin: 0 0 14px; padding: 0; flex: 1; }
  .plan-features li { font-size: 12.2px; color: var(--ink-soft); padding: 3px 0 3px 18px; position: relative; }
  .plan-features li::before { content: ""; position: absolute; left: 0; top: 9px; width: 7px; height: 7px; border-radius: 50%; background: var(--navy); opacity: .55; }
  .plan-note { color: var(--ink-soft); font-size: 11.6px; margin-bottom: 12px; }

  .keyvalue { word-break: break-all; min-width: 0; }
</style>
</head>
<body>
<div class="app" id="app">

  <header class="topbar">
    <div class="brand">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.25 2h7.09c.4 0 .78.16 1.06.44l4.66 4.66c.28.28.44.66.44 1.06v11.59A2.25 2.25 0 0 1 17.25 22H6.25A2.25 2.25 0 0 1 4 19.75V4.25A2.25 2.25 0 0 1 6.25 2zm0 1.5a.75.75 0 0 0-.75.75v15.5c0 .41.34.75.75.75h11a.75.75 0 0 0 .75-.75V9h-3.75A1.75 1.75 0 0 1 12.5 7.25V3.5H6.25zM14 4.31v2.94c0 .14.11.25.25.25h2.94L14 4.31z"/></svg>
      Licensing <span class="sub">VerseDocs</span>
    </div>
  </header>

  <div class="body" id="body" hidden>
    <h1 class="page-title">Licensing</h1>
    <div class="page-sub">Check this environment's license, buy a key for your whole Microsoft tenant or apply one you already have, and control what it sends.</div>

    <div id="loadError" class="error-banner" hidden></div>

    <div id="mainSections" hidden>

      <div class="card" id="checkoutResultCard" hidden>
        <h2>Your license key</h2>
        <div class="card-sub">Checkout just finished. This key is only shown once here, so save it now even if it also saves itself below.</div>

        <div id="checkoutResultPending" class="note-banner" hidden></div>
        <div id="checkoutResultError" class="error-banner" hidden></div>

        <div id="checkoutKeyBox" hidden>
          <div class="field">
            <label>License key</label>
            <div class="idvalue"><span class="status-value keyvalue" id="checkoutKeyValue">-</span><button class="btn btn-outline btn-sm" data-copy-target="checkoutKeyValue" type="button">Copy</button></div>
          </div>
          <div class="actions">
            <span id="checkoutSaveStatus" class="status-line" hidden></span>
          </div>
        </div>
      </div>

      <div class="card">
        <h2>Current status</h2>
        <div class="card-sub">Read live from this environment. Send the org and tenant IDs below if you ever need support to look you up.</div>

        <div id="statusError" class="error-banner" hidden></div>

        <div class="status-row">
          <span class="status-label">Status</span>
          <span id="licenseBadge" class="badge">-</span>
        </div>
        <div class="status-row">
          <span class="status-label">Edition</span>
          <span class="status-value" id="licenseEdition">-</span>
        </div>
        <div class="status-row">
          <span class="status-label">Reason code</span>
          <span class="status-value" id="licenseReason">-</span>
        </div>
        <div class="status-row">
          <span class="status-label">Scope</span>
          <span class="status-value" id="licenseScope">-</span>
        </div>
        <div class="status-row">
          <span class="status-label">Expiry</span>
          <span class="status-value" id="licenseExpiry">-</span>
        </div>
        <div class="status-row">
          <span class="status-label">Environment (org) ID</span>
          <span class="idvalue"><span class="status-value" id="orgId">-</span><button class="btn btn-outline btn-sm" data-copy-target="orgId" type="button">Copy</button></span>
        </div>
        <div class="status-row">
          <span class="status-label">Tenant ID</span>
          <span class="idvalue"><span class="status-value" id="tenantId">-</span><button class="btn btn-outline btn-sm" data-copy-target="tenantId" type="button">Copy</button></span>
        </div>
        <p class="status-hint" id="statusHint"></p>
      </div>

      <div class="card">
        <h2>Apply a license key</h2>
        <div class="card-sub">Already have a key, e.g. from a different environment or the purchase email? Paste it here to use it in this environment.</div>

        <div class="field">
          <label for="applyKeyInput">License key</label>
          <input type="text" id="applyKeyInput" placeholder="VDLIC1...." />
        </div>

        <div class="actions">
          <button class="btn btn-solid" id="applyKeyBtn" type="button">Apply key</button>
          <span id="applyKeyStatus" class="status-line" hidden></span>
        </div>
      </div>

      <div class="card" id="buyCard">
        <h2>Buy</h2>
        <div class="card-sub">One plan, bought once for your whole Microsoft tenant. The tenant ID above goes with it automatically, so the key that comes back already covers this environment and every other one in the same tenant; nothing to paste afterward.</div>

        <div class="plans">
          <div class="plan" id="planPremium">
            <h3>VerseDocs Premium</h3>
            <ul class="plan-features">
              <li>No watermark on generated documents</li>
              <li>The full utility action pack</li>
              <li>Every Dataverse environment in your Microsoft tenant, dev and test included</li>
              <li>Unlimited documents, users and flows</li>
            </ul>
            <button class="btn btn-solid" id="buyPremiumBtn" type="button">Buy VerseDocs Premium</button>
          </div>
        </div>
        <div class="plan-note" id="pricingNote">Pricing is shown at checkout.</div>

        <div class="actions">
          <span id="buyStatus" class="status-line" hidden></span>
        </div>
      </div>

      <div class="card" id="licensedCard" hidden>
        <h2>Your license</h2>
        <div class="card-sub" id="licensedNote">-</div>
      </div>

      <div class="card">
        <h2>Telemetry</h2>
        <div class="card-sub">Controls the periodic usage report this environment sends to VerseBlocks.</div>

        <div class="toggle-row">
          <input type="checkbox" id="optOutToggle" />
          <label for="optOutToggle">Disable telemetry</label>
        </div>
        <p class="disclosure">VerseDocs sends VerseBlocks this environment's org ID, tenant ID, license edition, template count, and usage counts by action and month when telemetry is on; it never sends document content, file names, template content, or field values, and you can turn it off above.</p>

        <div class="actions">
          <button class="btn btn-solid" id="saveOptOutBtn" type="button">Save</button>
          <span id="optOutStatus" class="status-line" hidden></span>
        </div>
      </div>

      <div class="card">
        <h2>Send feedback</h2>
        <div class="card-sub">A message straight to VerseBlocks. Only sent when you submit this form.</div>

        <div class="field">
          <label for="feedbackMessage">Message</label>
          <textarea id="feedbackMessage" placeholder="What's working, what isn't, what you'd like to see"></textarea>
        </div>
        <div class="field">
          <label for="feedbackEmail">Contact email (optional)</label>
          <input type="email" id="feedbackEmail" placeholder="ops@contoso.com" />
          <div class="hint">Leave blank to send feedback without a way for VerseBlocks to reply.</div>
        </div>

        <div class="actions">
          <button class="btn btn-solid" id="submitFeedbackBtn" type="button">Submit feedback</button>
          <span id="feedbackStatus" class="status-line" hidden></span>
        </div>
      </div>

    </div>

    <div id="loadingState" class="loading-state"><span class="spinner"></span>Loading&hellip;</div>
  </div>

  <div id="fatalError" class="fatal-error" hidden></div>
</div>

<script>
(function () {
  "use strict";

  // ======================================================================
  // Xrm bootstrap -- same "look in self/parent/top, fail with a clear
  // message rather than a blank page" pattern as configuration.htm /
  // templatestudio.htm.
  // ======================================================================
  function findXrm() {
    if (typeof Xrm !== "undefined" && Xrm.WebApi) { return Xrm; }
    try { if (window.parent && window.parent.Xrm && window.parent.Xrm.WebApi) { return window.parent.Xrm; } } catch (e) { /* cross-frame blocked */ }
    try { if (window.top && window.top.Xrm && window.top.Xrm.WebApi) { return window.top.Xrm; } } catch (e) { /* ignore */ }
    return null;
  }

  var xrm = findXrm();

  function showFatal(message) {
    document.getElementById("body").hidden = true;
    var el = document.getElementById("fatalError");
    el.hidden = false;
    el.innerHTML = '<div class="error-banner">' + escapeHtml(message) + "</div>";
  }

  if (!xrm) {
    showFatal("This page must be opened from inside a model-driven app (it calls Dataverse via the client API, which is only injected inside the app shell).");
    return;
  }

  function clientUrl() {
    try { return xrm.Utility.getGlobalContext().getClientUrl(); } catch (e) { return ""; }
  }

  var API_BASE = clientUrl() + "/api/data/v9.2/";

  function escapeHtml(s) {
    return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
      return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
    });
  }

  function webApiFetch(path, opts) {
    opts = opts || {};
    var headers = Object.assign({
      "OData-MaxVersion": "4.0",
      "OData-Version": "4.0",
      "Accept": "application/json",
      "Content-Type": "application/json; charset=utf-8",
    }, opts.headers || {});
    return fetch(API_BASE + path, {
      method: opts.method || "GET",
      headers: headers,
      credentials: "same-origin",
      body: opts.body,
    }).then(function (r) {
      if (!r.ok) {
        return r.text().then(function (t) { throw new Error("Request failed (" + r.status + "): " + t); });
      }
      return r;
    });
  }

  // ======================================================================
  // Environment variable read/write -- same pattern configuration.htm uses
  // for vdocs_EnabledRibbonTables: read via a plain fetch, write via
  // xrm.WebApi.updateRecord/createRecord (creating the environmentvariablevalue
  // row the first time, updating it after that). Also how the Buy button
  // below reads vdocs_LicensingBaseUrl -- it ships with no default, same
  // idiom as vdocs_TelemetryEndpoint. The plan below posts a product/tier
  // pair instead of a Stripe price ID, so there is no matching price
  // environment variable to read here.
  // ======================================================================
  function loadEnvVar(schemaName) {
    return webApiFetch(
      "environmentvariabledefinitions" +
      "?$select=environmentvariabledefinitionid,defaultvalue" +
      "&$filter=schemaname eq '" + schemaName + "'" +
      "&$expand=environmentvariabledefinition_environmentvariablevalue($select=environmentvariablevalueid,value)"
    ).then(function (r) { return r.json(); }).then(function (body) {
      var def = (body.value || [])[0];
      if (!def) {
        return { definitionId: null, valueId: null, value: null };
      }
      var values = def.environmentvariabledefinition_environmentvariablevalue;
      if (values && values.length > 0) {
        return { definitionId: def.environmentvariabledefinitionid, valueId: values[0].environmentvariablevalueid, value: values[0].value };
      }
      return { definitionId: def.environmentvariabledefinitionid, valueId: null, value: def.defaultvalue || null };
    });
  }

  function saveEnvVar(state, schemaName, newValue) {
    if (!state.definitionId) {
      return Promise.reject(new Error(schemaName + " is not defined in this environment. Import or update the VerseDocs managed solution, then reload this page."));
    }
    if (state.valueId) {
      return xrm.WebApi.updateRecord("environmentvariablevalue", state.valueId, { value: newValue });
    }
    // schemaname is environmentvariablevalue's own required attribute,
    // separate from the definition's schemaname (docs/LIMITS.md
    // "environmentvariablevalue's value column is Memo, not String, and
    // creation needs its own schemaname"); same "<schemaName>_Value"
    // convention scripts/lifecycle_proof.py and
    // VerseDocs.Plugins.Dataverse.EnvironmentVariableHelper.SetValue use.
    return xrm.WebApi.createRecord("environmentvariablevalue", {
      schemaname: schemaName + "_Value",
      value: newValue,
      "EnvironmentVariableDefinitionId@odata.bind": "/environmentvariabledefinitions(" + state.definitionId + ")",
    }).then(function (created) { state.valueId = created.id; });
  }

  function isTruthy(value) {
    return value === "true" || value === "1";
  }

  // ======================================================================
  // Custom API calls -- vdocs_SubmitFeedback (below) is an unbound Custom
  // API action, callable directly as a Web API POST to its own name (same
  // convention every vdocs_* action uses over the Dataverse connector's
  // "Perform an unbound action"; see docs/FLOWS.md). vdocs_Register used to
  // be called from this page too, from a "Register your organization"
  // section that has been removed: it was self-reported (anyone could type
  // anything) and the useful identity now comes from the Stripe purchase
  // email plus the org/tenant id this page already shows. vdocs_Register
  // itself is untouched -- vdocs_SendTelemetry still reads whatever it
  // stored the last time it was called (see TelemetryEnvironmentStore in
  // VerseDocs.Plugins), and it remains callable directly, e.g. from a Power
  // Automate flow (docs/FLOWS.md worked example 18).
  // ======================================================================
  function callCustomApi(name, body) {
    return webApiFetch(name, { method: "POST", body: JSON.stringify(body) })
      .then(function (r) { return r.json(); });
  }

  function setStatus(elId, message, isError) {
    var el = document.getElementById(elId);
    el.hidden = false;
    el.textContent = message;
    el.className = "status-line " + (isError ? "err" : "ok");
  }

  function setText(id, text) {
    document.getElementById(id).textContent = (text === null || text === undefined || text === "") ? "-" : text;
  }

  // ======================================================================
  // State
  // ======================================================================
  var state = {
    optOut: { definitionId: null, valueId: null, value: null },
    licensingBaseUrl: { definitionId: null, valueId: null, value: null },
  };

  /** This Microsoft tenant's id, as reported by vdocs_Ping and rendered in the
   * status card below. Stays null until that call succeeds, and Buy refuses to
   * start checkout while it is null rather than sending something else. */
  var executingTenantId = null;

  /** "loading" until the first vdocs_Ping answers, then "loaded" or "failed".
   * Only used to tell a shopper who clicked Buy early that the tenant id is
   * still on its way, rather than telling them it cannot be read at all. */
  var statusLoadState = "loading";

  // ======================================================================
  // Current status -- calls vdocs_Ping (unbound Custom API action via
  // Xrm.WebApi.online.execute) and renders the friendly hint plus the raw
  // reason code itself, since support tickets need the exact code
  // ("SignatureInvalid", "ScopeMismatch", etc.), not just the sentence.
  // ======================================================================
  var REASON_HINTS = {
    NoKey: "No license key is set. Buy one below, or add one to the vdocs_LicenseKey environment variable.",
    Malformed: "The stored license key could not be parsed. Re-paste it. A character was likely dropped or altered in transit.",
    SignatureInvalid: "The stored license key's signature does not verify. It was corrupted or tampered with; request a fresh key.",
    ScopeMismatch: "The stored license key is valid but scoped to a different org/tenant than this one. Copy the org/tenant ID above and request a key scoped to it.",
    Expired: "The stored license key has passed its expiry date. Request a renewal.",
  };

  var pingRequest = {
    getMetadata: function () {
      return {
        boundParameter: null,
        parameterTypes: {},
        operationType: 0, // Action
        operationName: "vdocs_Ping",
      };
    },
  };

  // ======================================================================
  // Plan visibility -- driven off lic.isLicensed, never off the edition
  // string, because a key can be present and still be Expired or
  // ScopeMismatch, and someone in that state is not entitled to anything:
  // they need to see Buy again, the same as someone with no key at all.
  //
  // There is one plan now, so this is a straight either/or: a valid license
  // means nothing to sell and the Buy card goes away entirely; anything else
  // means Buy. The Buy card starts visible in the markup on purpose, so a
  // vdocs_Ping failure (which leaves this function un-run) leaves an
  // unlicensed customer able to buy rather than staring at a page with no
  // way to pay.
  // ======================================================================
  /** What the license on file actually covers, in the customer's terms. Reads
   * the scope vdocs_Ping reported ("Tenant" or "Org").
   *
   * This returns the WHOLE message, lead sentence included, because the lead
   * has to agree with the scope. It used to be a fixed "This environment is
   * licensed" with only the second sentence varying, which read as a
   * contradiction on a tenant key: the license covers the tenant, not the one
   * environment the reader happens to be looking at.
   *
   * Org scope is legacy. VerseDocs is sold as one key per tenant, so an
   * org-scoped key predates that and is described as such rather than being
   * presented as a current option someone should expect. */
  function scopeCoverage(scope) {
    var normalized = String(scope == null ? "" : scope).toLowerCase();
    if (normalized === "tenant") {
      return "Your Microsoft tenant is licensed, so there is nothing to buy. One key covers every Dataverse environment in the tenant -- production, dev and test alike, including ones you have not installed VerseDocs into yet.";
    }
    if (normalized === "org") {
      return "This environment is licensed, so there is nothing to buy here. This key covers this environment only: it was issued before VerseDocs moved to one key per tenant. Every other environment keeps producing watermarked documents until it has a key of its own -- ask VerseBlocks about exchanging it for a tenant key that covers them all.";
    }
    return "This environment is licensed. Its scope reads as \"" + (scope || "unknown") + "\" in the status above, which is neither of the two values VerseDocs issues; contact VerseBlocks if that is not what you expected.";
  }

  function renderPlans(lic) {
    var licensed = !!lic.isLicensed;
    document.getElementById("buyCard").hidden = licensed;
    document.getElementById("licensedCard").hidden = !licensed;

    if (!licensed) { return; }

    document.getElementById("licensedNote").textContent = scopeCoverage(lic.scope);
  }

  function renderStatus(payload) {
    setText("orgId", payload.executingOrgId);
    setText("tenantId", payload.executingTenantId);

    // vdocs_Ping is the only place this page can read the tenant id from
    // (see the Buy section below), so capture it at the same moment the
    // status card renders it. Nothing else on the page writes this.
    var pingTenantId = payload.executingTenantId;
    executingTenantId = (typeof pingTenantId === "string" && pingTenantId.trim()) ? pingTenantId.trim() : null;
    statusLoadState = "loaded";

    var lic = payload.license || {};
    var badge = document.getElementById("licenseBadge");
    if (lic.isLicensed) {
      badge.textContent = "Licensed";
      badge.className = "badge licensed";
    } else {
      badge.textContent = "Trial (watermarked output)";
      badge.className = "badge trial";
    }
    setText("licenseEdition", lic.edition);
    setText("licenseReason", lic.reason);
    setText("licenseScope", lic.scope);
    setText("licenseExpiry", lic.isPerpetual ? "Perpetual (never expires)" : lic.expiryUtc);
    document.getElementById("statusHint").textContent = REASON_HINTS[lic.reason] || "";

    renderPlans(lic);
  }

  /** Resolves with the parsed vdocs_Ping payload (not just void) so callers like the
   * apply-key flow below can report the freshly-resolved reason without a second call. */
  /** Reason code from the most recent vdocs_Ping render; null before the first. */
  var lastLicenseReason = null;

  function loadStatus() {
    statusLoadState = "loading";
    return xrm.WebApi.online.execute(pingRequest).then(function (response) {
      return response.json().then(function (body) {
        var payload = JSON.parse(body.Result);
        // Recorded so Apply key can tell whether it is about to displace a
        // working license, without spending a second vdocs_Ping to ask.
        lastLicenseReason = ((payload || {}).license || {}).reason || null;
        renderStatus(payload);
        return payload;
      });
    }).catch(function (error) {
      // Buy reads this to tell "still loading" apart from "asked and got
      // nothing"; the caller still sees the rejection and renders it.
      statusLoadState = "failed";
      throw error;
    });
  }

  // ======================================================================
  // Copy buttons -- fallback-to-execCommand technique for browsers/contexts
  // without navigator.clipboard (the model-driven app iframe has been seen
  // to block it depending on host).
  // ======================================================================
  function fallbackCopy(text) {
    var ta = document.createElement("textarea");
    ta.value = text;
    ta.style.position = "fixed";
    ta.style.opacity = "0";
    document.body.appendChild(ta);
    ta.select();
    try { document.execCommand("copy"); } catch (e) { /* ignore */ }
    document.body.removeChild(ta);
  }

  function wireCopyButtons() {
    var buttons = document.querySelectorAll("button[data-copy-target]");
    for (var i = 0; i < buttons.length; i++) {
      buttons[i].addEventListener("click", function (ev) {
        var btn = ev.currentTarget;
        var targetId = btn.getAttribute("data-copy-target");
        var text = document.getElementById(targetId).textContent;
        var original = btn.textContent;
        var reset = function () { btn.textContent = original; };
        var onCopied = function () { btn.textContent = "Copied"; setTimeout(reset, 1500); };
        if (navigator.clipboard && navigator.clipboard.writeText) {
          navigator.clipboard.writeText(text).then(onCopied, function () { fallbackCopy(text); onCopied(); });
        } else {
          fallbackCopy(text);
          onCopied();
        }
      });
    }
  }

  // ======================================================================
  // Buy -- this runs inside the customer's own environment, so it can read
  // the tenant ID Stripe Checkout could never capture on its own, and send it
  // along so Cartographer's webhook can issue a key already scoped to this
  // tenant instead of parking the sale for support to chase down.
  //
  // The tenant ID is NOT available from Xrm.Utility.getGlobalContext():
  // organizationSettings exposes organizationId, and nothing there carries the
  // Entra tenant. vdocs_Ping's executingTenantId is the only source, which is
  // why renderStatus above captures it into executingTenantId as it renders.
  // ======================================================================

  /** This web resource's own name, the way Dataverse's URL-addressable-page
   * scheme (main.aspx?...&pagetype=webresource&webresourceName=...) names it --
   * the same "vdocs_/<file>.htm" string generateandattach.js already uses with
   * Xrm.Navigation.navigateTo's pageType:"webresource". */
  var LICENSING_WEBRESOURCE_NAME = "vdocs_/licensing.htm";

  /** getCurrentAppUrl() returns a URL already carrying this app's appid, e.g.
   * "https://org.crm.dynamics.com/main.aspx?appid=<guid>". Falls back to a bare
   * main.aspx off the client URL if it is unavailable, which still lands in
   * Dataverse even if not guaranteed inside this exact app's shell. */
  function currentAppUrl() {
    try {
      var url = xrm.Utility.getGlobalContext().getCurrentAppUrl();
      if (url) { return url; }
    } catch (e) { /* fall through */ }
    return clientUrl() + "/main.aspx";
  }

  /**
   * Where Stripe returns the browser to when checkout finishes or is
   * abandoned: this same Licensing page, inside the model-driven app shell,
   * built from the app URL plus the webresource query params Dataverse's own
   * URL-addressable-page scheme understands.
   *
   * When withSessionId is true, appends `data={CHECKOUT_SESSION_ID}` --
   * Stripe substitutes that literal placeholder with the real Checkout
   * Session id before it ever redirects anyone, the same convention Stripe's
   * own docs use for a plain query string parameter. This page reads it back
   * out of window.location.search the same way generateandattach.htm reads
   * the `data` param Xrm.Navigation.navigateTo's pageType:"webresource"
   * passes through.
   */
  function buildReturnUrl(withSessionId) {
    var base = currentAppUrl();
    var separator = base.indexOf("?") === -1 ? "?" : "&";
    var url = base + separator + "pagetype=webresource&webresourceName=" + LICENSING_WEBRESOURCE_NAME;
    if (withSessionId) {
      url += "&data={CHECKOUT_SESSION_ID}";
    }
    return url;
  }

  /**
   * Opens Stripe Checkout at the TOP level, never inside this page.
   *
   * A model-driven app web resource is an iframe, so assigning
   * window.location.href here loaded Stripe inside that frame and Stripe
   * refused outright: "Stripe Checkout is not able to run in an iFrame.
   * Please redirect to Checkout at the top level." Nothing about the
   * checkout session was wrong; it simply cannot render where we put it.
   *
   * Xrm.Navigation.openUrl is the supported way for a web resource to send
   * someone to an external URL, and the platform handles escaping the frame.
   * The fallbacks cover a stripped Xrm: window.open first, since a new tab
   * always escapes, then window.top as a last resort (which can itself throw
   * if the top frame is cross-origin, hence the try).
   *
   * Used by Checkout (see openCheckout below), the only flow on this page
   * that hands the browser to a Stripe-hosted page.
   */
  function openTopLevel(url, openedMessage) {
    try {
      if (xrm && xrm.Navigation && xrm.Navigation.openUrl) {
        xrm.Navigation.openUrl(url);
        setStatus("buyStatus", openedMessage, false);
        return;
      }
    } catch (e) { /* fall through */ }

    try {
      var opened = window.open(url, "_blank");
      if (opened) {
        setStatus("buyStatus", openedMessage, false);
        return;
      }
    } catch (e) { /* fall through */ }

    try {
      window.top.location.href = url;
      return;
    } catch (e) { /* fall through */ }

    // Everything blocked. Never leave someone staring at a dead button:
    // hand over the link so they can open it themselves.
    setStatus("buyStatus", "Your browser blocked the window. Open this link to continue: " + url, true);
  }

  function openCheckout(url) {
    openTopLevel(url, "Checkout opened in a new tab. Finish there, then come back to this page.");
  }

  /**
   * The single plan. Cartographer resolves this product/tier pair to whatever
   * Stripe price is live for it (StripePriceConfig.PriceToProductTier), which
   * is why no price ID and no amount is baked into this page: prices change,
   * and a stale number sitting inside an installed customer environment is
   * worse than no number at all.
   */
  var CHECKOUT_PRODUCT = "versedocs";
  // "professional", NOT "premium". Premium is the customer-facing plan NAME;
  // the tier string is a load-bearing key into Cartographer's price map
  // (StripePriceConfig: price_1U56iu... = versedocs/professional) and into
  // VerseDocsLicenseIssuer.MapTierToEdition, where "professional" is what maps
  // to the full-entitlement edition. Sending "premium" matches no registered
  // price, and checkout fails with "Could not resolve a unique price for
  // product 'versedocs' tier 'premium'" -- which is exactly what shipped when
  // this page collapsed to one plan and this constant was set to the name on
  // the button instead of the tier in the price map.
  var CHECKOUT_TIER = "professional";

  function startCheckout(btn) {
    if (!state.licensingBaseUrl.value) {
      setStatus("buyStatus", "Buying isn't set up in this environment yet. Ask whoever manages VerseDocs here to set the vdocs_LicensingBaseUrl environment variable.", true);
      return;
    }

    // No tenant id, no purchase. The plan is tenant-scoped, so the tenant id
    // IS the thing being bought; falling back to the org id would quietly sell
    // someone a license covering one environment when they are paying for all
    // of them, and they would not find out until the second environment
    // watermarked its output.
    if (!executingTenantId) {
      if (statusLoadState === "loading") {
        setStatus("buyStatus", "Still reading this environment's tenant ID from vdocs_Ping. Wait for the Tenant ID row above to fill in, then click Buy again.", true);
        return;
      }
      setStatus(
        "buyStatus",
        "Could not read this environment's tenant ID, so checkout can't be started from here. It comes from vdocs_Ping, the same call that fills in Current status above, so the Tenant ID row there is empty too. Reload the page, and if it stays empty check that VerseDocs is installed and that vdocs_Ping runs. This page will not buy without it: the plan covers your whole tenant, and a purchase without the tenant ID would license the wrong thing.",
        true
      );
      return;
    }

    var base = state.licensingBaseUrl.value.replace(/\/+$/, "");

    btn.disabled = true;
    setStatus("buyStatus", "Starting checkout…", false);

    fetch(base + "/billing/checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        product: CHECKOUT_PRODUCT,
        tier: CHECKOUT_TIER,
        // Tenant-scoped, explicitly. scope/scopeId are the names
        // StripeWebhookFunction reads back off the Stripe session
        // (metadata.scope, then client_reference_id or metadata.scopeId) when
        // it decides what a VDLIC1 key is scoped to; tenantId carries the same
        // value under the name the checkout endpoint's own request shape uses.
        // orgId is deliberately NOT sent: that field is what makes the issuer
        // mint an org-scoped key, and this plan is not org-scoped.
        scope: "tenant",
        scopeId: executingTenantId,
        tenantId: executingTenantId,
        // Where Stripe sends the browser afterwards. Checkout runs in a
        // separate top-level tab (see openCheckout above), so this is a real
        // top-level navigation, not a load inside this page's iframe -- it
        // lands that tab back on this Licensing page inside the app shell,
        // carrying Stripe's session id so the page can fetch the issued key.
        // cancelUrl deliberately omits the session id: an abandoned checkout
        // has no key to fetch, and this is a normal, silent load.
        successUrl: buildReturnUrl(true),
        cancelUrl: buildReturnUrl(false),
      }),
    }).then(function (r) {
      return r.json().then(function (parsed) { return { ok: r.ok, body: parsed }; });
    }).then(function (result) {
      btn.disabled = false;
      if (result.ok && result.body && result.body.success && result.body.url) {
        openCheckout(result.body.url);
        return;
      }
      var message = (result.body && result.body.message) ? result.body.message : "Could not start checkout.";
      setStatus("buyStatus", message, true);
    }, function (error) {
      btn.disabled = false;
      setStatus("buyStatus", "Could not reach the licensing service: " + (error && error.message ? error.message : String(error)), true);
    });
  }

  /** One plan, one button. There is nothing to upgrade to, so this is a
   * plain new-subscription checkout every time. */
  function wireBuyButton() {
    document.getElementById("buyPremiumBtn").addEventListener("click", function (ev) {
      startCheckout(ev.currentTarget);
    });
  }

  // ======================================================================
  // Checkout result -- runs once on load if Stripe just sent the browser
  // back here with a session id (see buildReturnUrl above). checkout/result
  // is ONE-SHOT: it nulls the stored key after the first successful read, so
  // the key must be shown on screen before this code tries anything else
  // with it. If saving it into vdocs_LicenseKey fails for any reason (most
  // likely a buyer without privileges to write environment variables), the
  // key stays on screen with instructions to paste it in by hand -- a silent
  // failure here would mean a paying customer walks away with nothing.
  // ======================================================================
  function sessionIdFromUrl() {
    try {
      var params = new URLSearchParams(window.location.search);
      return params.get("data") || null;
    } catch (e) {
      return null;
    }
  }

  function showCheckoutKey(key) {
    document.getElementById("checkoutResultCard").hidden = false;
    document.getElementById("checkoutResultError").hidden = true;
    document.getElementById("checkoutKeyBox").hidden = false;
    setText("checkoutKeyValue", key);
  }

  function showCheckoutError(message) {
    document.getElementById("checkoutResultCard").hidden = false;
    document.getElementById("checkoutKeyBox").hidden = true;
    document.getElementById("checkoutResultPending").hidden = true;
    var el = document.getElementById("checkoutResultError");
    el.hidden = false;
    el.textContent = message;
  }

  /** "Still being created" is NOT an error, and must never be shown as one.
   * Stripe redirects the buyer back the instant payment succeeds, while the
   * webhook that mints the key runs afterwards, so the very first call on
   * return legitimately finds nothing yet. Cartographer says so explicitly
   * with {"success":false,"pending":true}, and treating that as failure would
   * show a red banner to someone who has just paid, seconds before their key
   * appears. */
  function showCheckoutPending(message) {
    document.getElementById("checkoutResultCard").hidden = false;
    document.getElementById("checkoutKeyBox").hidden = true;
    document.getElementById("checkoutResultError").hidden = true;
    var el = document.getElementById("checkoutResultPending");
    el.hidden = false;
    el.textContent = message;
  }

  /** Loads the vdocs_LicenseKey environment variable state on demand (not
   * part of the boot Promise.all, since most page loads carry no key to
   * save) and writes newKey into it, following the same
   * loadEnvVar/saveEnvVar shape every other environment variable on this
   * page uses. */
  function saveLicenseKey(newKey) {
    return loadEnvVar("vdocs_LicenseKey").then(function (licenseKeyState) {
      return saveEnvVar(licenseKeyState, "vdocs_LicenseKey", newKey);
    });
  }

  /** Used by the checkout-result flow: it displays a freshly-issued key on screen first, then
   * attempts to save it into vdocs_LicenseKey, and must report a failed save without ever losing
   * the key that is already on screen. */
  function trySaveLicenseKey(key) {
    var statusElId = "checkoutSaveStatus";
    setStatus(statusElId, "Saving to vdocs_LicenseKey…", false);
    saveLicenseKey(key).then(
      function () {
        setStatus(statusElId, "Saved to vdocs_LicenseKey. Refreshing status below.", false);
        loadStatus().catch(function (error) {
          var el = document.getElementById("statusError");
          el.hidden = false;
          el.textContent = "Could not load license status: " + (error && error.message ? error.message : String(error));
        });
      },
      function (error) {
        setStatus(
          statusElId,
          "Could not save this automatically (" + (error && error.message ? error.message : String(error)) +
            "). Copy the key above and paste it into the vdocs_LicenseKey environment variable's value yourself " +
            "(maker portal, Solutions, your solution, Environment Variables, vdocs_LicenseKey), then Publish all customizations.",
          true
        );
      }
    );
  }

  // Stripe redirects back the moment payment succeeds; the webhook that mints
  // the key runs after that. Five tries at three seconds covers the normal gap
  // without leaving someone staring at a spinner if something is genuinely
  // wrong, and the email carries the key either way.
  var CHECKOUT_RESULT_MAX_ATTEMPTS = 5;
  var CHECKOUT_RESULT_RETRY_MS = 3000;

  function runCheckoutResult(sessionId, attempt) {
    attempt = attempt || 1;
    if (!state.licensingBaseUrl.value) {
      showCheckoutError(
        "Your payment went through, but this environment has no vdocs_LicensingBaseUrl set, so the key " +
          "cannot be fetched from here. Check the email VerseBlocks sent you for the key, or ask whoever " +
          "manages VerseDocs here to set vdocs_LicensingBaseUrl and reload this page."
      );
      return;
    }

    var base = state.licensingBaseUrl.value.replace(/\/+$/, "");

    fetch(base + "/checkout/result", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ sessionId: sessionId }),
    }).then(function (r) {
      return r.json().then(function (parsed) { return { ok: r.ok, body: parsed }; });
    }).then(function (result) {
      if (result.ok && result.body && result.body.success && result.body.licenseKey) {
        // Display first, unconditionally, before attempting anything else.
        showCheckoutKey(result.body.licenseKey);
        trySaveLicenseKey(result.body.licenseKey);
        return;
      }

      // Key not minted yet. Wait and ask again rather than calling it a
      // failure: this is the expected first answer, not an exception.
      if (result.body && result.body.pending === true) {
        if (attempt < CHECKOUT_RESULT_MAX_ATTEMPTS) {
          showCheckoutPending("Your payment went through. Creating your license key, this usually takes a few seconds.");
          window.setTimeout(function () {
            runCheckoutResult(sessionId, attempt + 1);
          }, CHECKOUT_RESULT_RETRY_MS);
          return;
        }
        // Still pending after a fair wait. Not an error either: the key is
        // coming, it is just slower than this page is willing to sit and
        // wait, and the email carries it regardless.
        showCheckoutPending(
          (result.body.message ? result.body.message : "Your license is still being created.") +
            " You can reload this page shortly, or use the key in the email VerseBlocks sent you."
        );
        return;
      }

      var message = (result.body && result.body.message) ? result.body.message :
        "Could not retrieve a license key for this checkout session. It may already have been claimed by an " +
          "earlier load of this page, or the session may not be recognized. Check the email VerseBlocks sent " +
          "you for the key, or contact support with your order details.";
      showCheckoutError(message);
    }, function (error) {
      showCheckoutError(
        "Could not reach the licensing service to retrieve your key: " +
          (error && error.message ? error.message : String(error)) +
          ". Check the email VerseBlocks sent you for the key. Reloading this page will not fetch it again " +
          "once it has already been retrieved successfully."
      );
    });
  }

  // ======================================================================
  // Apply a license key -- for someone who already has a key (a different
  // environment, the purchase email, support) and just needs to put it to
  // use here. Reuses the same saveEnvVar/loadEnvVar shape the checkout
  // auto-save above already uses for vdocs_LicenseKey, then re-runs
  // vdocs_Ping so the outcome shown is the real, freshly-validated status,
  // not an assumption that pasting a key means it is good.
  // ======================================================================
  /** Shape of a VerseDocs key: "VDLIC1.<payload>.<signature>", both parts
   * base64url. Checked in the browser BEFORE anything is written, so an
   * obviously-wrong value (a stray word, a truncated paste) can never replace
   * a working license. It deliberately proves nothing about the signature --
   * that is the server's job -- it only rules out what cannot possibly be a
   * key. */
  var KEY_SHAPE = /^VDLIC1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;

  /** Lower-cases the first letter of a REASON_HINTS sentence so it can be
   * embedded mid-sentence without reading as "but The stored license key...". */
  function uncapitalise(text) {
    return text ? text.charAt(0).toLowerCase() + text.slice(1) : text;
  }

  function applyKeyOutcomeMessage(lic) {
    if (lic.reason === "Valid") {
      return "Key applied. License is valid" + (lic.edition ? " (" + lic.edition + ")." : ".");
    }
    var hint = REASON_HINTS[lic.reason];
    return "Key applied, but " + (uncapitalise(hint) || ("status is " + lic.reason + "."));
  }

  document.getElementById("applyKeyBtn").addEventListener("click", function () {
    var btn = document.getElementById("applyKeyBtn");
    var input = document.getElementById("applyKeyInput");
    var key = input.value;

    if (!key || !key.trim()) {
      setStatus("applyKeyStatus", "Enter a license key to apply.", true);
      return;
    }
    key = key.trim();

    // Refuse anything that cannot be a key BEFORE writing. This used to save
    // first and validate afterwards, which meant typing anything at all into
    // this box replaced whatever was in vdocs_LicenseKey -- on a licensed
    // environment that destroyed a working license, and the only feedback was
    // a message saying the key had been saved.
    if (!KEY_SHAPE.test(key)) {
      setStatus(
        "applyKeyStatus",
        "That does not look like a VerseDocs license key, so nothing was changed. A key looks like " +
          "\u201cVDLIC1.\u2026.\u2026\u201d and is around 600 characters. Paste the whole line from your " +
          "purchase email.",
        true
      );
      return;
    }

    btn.disabled = true;
    setStatus("applyKeyStatus", "Saving to vdocs_LicenseKey…", false);

    // Remember what is there now, so a key that turns out not to be valid for
    // THIS environment (wrong tenant, expired, tampered) can be rolled back
    // instead of leaving the customer worse off than before they pasted. The
    // previous *reason* comes from the last render rather than a fresh ping,
    // so a click still costs exactly one vdocs_Ping.
    var hadWorkingLicense = lastLicenseReason === "Valid";
    loadEnvVar("vdocs_LicenseKey").then(
      function (previousState) {
        var previousValue = previousState ? previousState.value : null;

        return saveLicenseKey(key).then(function () {
          return loadStatus().then(function (payload) {
            var lic = payload.license || {};
            if (lic.reason === "Valid") {
              btn.disabled = false;
              setStatus("applyKeyStatus", applyKeyOutcomeMessage(lic), false);
              return;
            }

            if (!hadWorkingLicense) {
              // Nothing good was displaced, so leave the new key in place: the
              // reason code is the most useful thing to show, and re-pasting a
              // corrected key is the obvious next step.
              btn.disabled = false;
              setStatus("applyKeyStatus", applyKeyOutcomeMessage(lic), true);
              return;
            }

            // Roll back to the license that was working.
            setStatus("applyKeyStatus", "That key is not valid here \u2014 restoring your previous license\u2026", true);
            return saveLicenseKey(previousValue || "").then(
              function () {
                return loadStatus().then(function (restored) {
                  var restoredLic = restored.license || {};
                  btn.disabled = false;
                  setStatus(
                    "applyKeyStatus",
                    "Key rejected (" + uncapitalise(REASON_HINTS[lic.reason] || ("status is " + lic.reason + ".")) +
                      ") Your previous license was restored and is " +
                      (restoredLic.reason === "Valid" ? "still valid." : "now reporting " + restoredLic.reason + "."),
                    true
                  );
                });
              },
              function (restoreError) {
                btn.disabled = false;
                setStatus(
                  "applyKeyStatus",
                  "Key rejected (" + lic.reason + "), and the previous license could NOT be restored automatically (" +
                    (restoreError && restoreError.message ? restoreError.message : String(restoreError)) +
                    "). Re-paste your working key, or set vdocs_LicenseKey in the maker portal.",
                  true
                );
              }
            );
          });
        });
      }
    ).catch(function (error) {
      btn.disabled = false;
      // A write failure here is not evidence the key itself is bad -- most
      // likely a non-admin user lacking privilege to write environment
      // variables, the same situation as the checkout auto-save above.
      setStatus(
        "applyKeyStatus",
        "Could not apply this key (" + (error && error.message ? error.message : String(error)) +
          "). This usually means you do not have permission to write environment variables in this " +
          "environment; ask an admin, or set vdocs_LicenseKey yourself in the maker portal (Solutions, " +
          "your solution, Environment Variables, vdocs_LicenseKey), then Publish all customizations.",
        true
      );
    });
  });

  // ======================================================================
  // Telemetry opt-out
  // ======================================================================
  document.getElementById("saveOptOutBtn").addEventListener("click", function () {
    var btn = document.getElementById("saveOptOutBtn");
    var checked = document.getElementById("optOutToggle").checked;
    var newValue = checked ? "true" : "false";

    btn.disabled = true;
    Promise.resolve(saveEnvVar(state.optOut, "vdocs_TelemetryOptOut", newValue)).then(
      function () {
        btn.disabled = false;
        state.optOut.value = newValue;
        setStatus("optOutStatus", "Saved.", false);
      },
      function (error) {
        btn.disabled = false;
        setStatus("optOutStatus", "Could not save: " + (error && error.message ? error.message : String(error)), true);
      }
    );
  });

  // ======================================================================
  // Feedback
  // ======================================================================
  document.getElementById("submitFeedbackBtn").addEventListener("click", function () {
    var btn = document.getElementById("submitFeedbackBtn");
    var message = document.getElementById("feedbackMessage").value;
    var contactEmail = document.getElementById("feedbackEmail").value;

    if (!message || !message.trim()) {
      setStatus("feedbackStatus", "A message is required.", true);
      return;
    }

    btn.disabled = true;
    var payload = { Message: message };
    if (contactEmail && contactEmail.trim()) {
      payload.ContactEmail = contactEmail;
    }

    callCustomApi("vdocs_SubmitFeedback", payload).then(
      function (result) {
        btn.disabled = false;
        // Report what actually happened. A successful call does not mean the
        // feedback was delivered: with no endpoint configured nothing leaves
        // the environment, and saying "Sent" there would lose the message
        // while thanking the user for it.
        var parsed = null;
        try { parsed = result && result.Result ? JSON.parse(result.Result) : null; } catch (e) { parsed = null; }
        var delivered = !!(parsed && parsed.submitted);
        if (delivered) {
          setStatus("feedbackStatus", "Sent. Thank you.", false);
          document.getElementById("feedbackMessage").value = "";
          document.getElementById("feedbackEmail").value = "";
          return;
        }
        // Deliberately keep the text in the box so nothing is lost.
        setStatus(
          "feedbackStatus",
          "Could not be delivered, so your message has been left in the box. VerseDocs has no feedback endpoint configured in this environment.",
          true
        );
      },
      function (error) {
        btn.disabled = false;
        setStatus("feedbackStatus", "Could not send: " + (error && error.message ? error.message : String(error)), true);
      }
    );
  });

  // ======================================================================
  // Boot
  // ======================================================================
  document.getElementById("body").hidden = false;

  Promise.all([
    loadEnvVar("vdocs_TelemetryOptOut"),
    loadEnvVar("vdocs_LicensingBaseUrl"),
  ]).then(function (results) {
    state.optOut = results[0];
    state.licensingBaseUrl = results[1];

    document.getElementById("loadingState").hidden = true;
    document.getElementById("mainSections").hidden = false;

    document.getElementById("optOutToggle").checked = isTruthy(state.optOut.value);

    wireCopyButtons();
    wireBuyButton();

    // Only present when Stripe just sent the browser back here (see
    // buildReturnUrl); a normal load has no `data` param and this does
    // nothing at all.
    var sessionId = sessionIdFromUrl();
    if (sessionId) {
      runCheckoutResult(sessionId);
    }

    // The status card's own vdocs_Ping call is independent of the environment
    // variable load above: if it fails, the rest of the page (buy, apply key,
    // telemetry, feedback) should still work, so its failure is shown inline
    // in the status card rather than blocking the whole page.
    loadStatus().catch(function (error) {
      var el = document.getElementById("statusError");
      el.hidden = false;
      el.textContent = "Could not load license status: " + (error && error.message ? error.message : String(error));
    });
  }, function (error) {
    document.getElementById("loadingState").hidden = true;
    document.getElementById("loadError").hidden = false;
    document.getElementById("loadError").textContent =
      "Could not load this page's settings: " + (error && error.message ? error.message : String(error));
  });
})();
</script>
</body>
</html>
