# subscription/2.1.0/assets/js/admin/plan-forms.js

Subscriptions for WooCommerce with Stripe Recurring Payments, version 2.1.0. 605 lines.

- Page: https://pluginprobe.com/plugins/subscription/2.1.0/code/assets/js/admin/plan-forms.js
- Raw: https://pluginprobe.com/plugins/subscription/2.1.0/raw/assets/js/admin/plan-forms.js
- Modified: 2026-09-14T12:17:12+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/subscription/2.1.0/code/assets/js/admin/plan-forms.js#L10-L20`.

```javascript
/**
 * Shared plan-group + selling-plan (term) form logic.
 *
 * Owns the Create-Plan-Group modal and the Add / Edit Duration modal:
 * plan-type selection, field collection, the /groups and /terms REST writes,
 * and prefilling. Used by BOTH the Plans admin screen
 * (assets/js/admin/plans.js) and the product-editor Subscription tab
 * (assets/js/admin/product-plans.js), so the term payload contract lives in a
 * single place.
 *
 * On success it does NOT navigate. It closes the modal and dispatches a DOM
 * event so each host page decides what happens next:
 *   - "subscrpt:group-created" detail: { group }
 *   - "subscrpt:term-saved"    detail: { groupId, termId, editing }
 *
 * Config is read from window.subscrptPlanForms (restUrl, nonce, i18n),
 * localized on every page that renders these modals.
 */
(function () {
  "use strict";

  var cfg = window.subscrptPlanForms || {};
  var i18n = cfg.i18n || {};

  // day/week/month/year ⇄ billing_interval integer.
  var INTERVAL_TO_INT = { day: 1, week: 2, month: 3, year: 4 };
  var INT_TO_INTERVAL = { 1: "day", 2: "week", 3: "month", 4: "year" };

  // REST calls, the button-busy lock and every message here come from the
  // shared component (admin-components/save.js).
  var save = window.WPSubsSave.bind({ restUrl: cfg.restUrl, nonce: cfg.nonce, i18n: i18n });
  var api = save.api;
  var setLoading = save.busy;

  /* ------------------------------------------------------------------ *
   * One-time purchase: the prices follow the toggle.
   * ------------------------------------------------------------------ */

  /**
   * The block one of these controls belongs to. Three shapes carry it — a
   * section below the table in the product editor, and on the Plans screen a
   * table row (variable) or a card (simple) — so they all carry one hook.
   *
   * @param {HTMLElement} el A control inside the block.
   * @return {HTMLElement|null}
   */
  function oneTimeScope(el) {
    return el.closest("[data-subscrpt-onetime]");
  }

  /**
   * Fill a one-time block's empty price fields from the product's current
   * WooCommerce price.
   *
   * The one-time price IS the product's native price, so an empty field is not
   * a choice — it is the merchant being asked to retype what the product
   * already costs. The General tab's inputs are the source because they carry
   * the price typed in this session, which no server render can know about.
   * Only empty fields are filled, so a saved or hand-entered price stands.
   *
   * On the Plans screen those inputs do not exist and this does nothing; that
   * screen seeds its fields server-side instead.
   *
   * @param {HTMLElement} scope A one-time row or card.
   */
  function seedOneTimePrices(scope) {
    [
      ["price", "_regular_price"],
      ["offer", "_sale_price"],
    ].forEach(function (pair) {
      var input = scope.querySelector('[data-ot-field="' + pair[0] + '"]');
      var native = document.getElementById(pair[1]);
      if (!input || !native || "" !== String(input.value).trim()) {
        return;
      }
      var value = String(native.value).trim();
      if ("" !== value) {
        input.value = value;
      }
    });
  }

  /**
   * Show the prices only while one-time purchase is switched on.
   *
   * Off, the price fields read as a second regular price sitting under the
   * plan's own — the merchant has no way to tell they do not apply. Revealing
   * them on the toggle says which is which.
   *
   * @param {HTMLElement} scope A one-time row or card.
   */
  function syncOneTime(scope) {
    var toggle = scope.querySelector("[data-subscrpt-onetime-enable]");
    if (!toggle) {
      return;
    }
    var on = toggle.checked;

    if (on) {
      seedOneTimePrices(scope);
    }

    scope.querySelectorAll("[data-subscrpt-onetime-price]").forEach(function (el) {
      el.style.display = on ? "" : "none";
    });

    // The card keeps its whole body behind the toggle, prices and all.
    var body = scope.querySelector("[data-subscrpt-onetime-body]");
    if (body) {
      body.style.display = on ? "" : "none";
    }
  }

  // Lives here because both the Plans screen and the product editor load this
  // module, and both render the block.
  document.addEventListener("change", function (e) {
    var toggle = e.target.closest("[data-subscrpt-onetime-enable]");
    var scope = toggle && oneTimeScope(toggle);
    if (scope) {
      syncOneTime(scope);
    }
  });

  /**
   * Open a modal by id via the shared helper.
   *
   * @param {string} id Modal id.
   */
  function openModal(id) {
    if (window.WPSubsModal && typeof window.WPSubsModal.open === "function") {
      window.WPSubsModal.open(id);
    }
  }

  /**
   * Close a modal by id via the shared helper.
   *
   * @param {string} id Modal id.
   */
  function closeModal(id) {
    if (window.WPSubsModal && typeof window.WPSubsModal.close === "function") {
      window.WPSubsModal.close(id);
    }
  }

  /**
   * Read the current value of an advanced-select (its hidden input).
   *
   * @param {HTMLElement} root .wpsubs-adv-select root.
   * @return {string}
   */
  function advValue(root) {
    var hidden = root.querySelector('input[type="hidden"]');
    return hidden ? hidden.value : "";
  }

  /**
   * Programmatically set an advanced-select's value + visible label.
   *
   * @param {HTMLElement} root  .wpsubs-adv-select root.
   * @param {string}      value Option value to select.
   */
  function setAdvSelect(root, value) {
    value = value == null ? "" : String(value);
    var hidden = root.querySelector('input[type="hidden"]');
    if (hidden) {
      hidden.value = value;
    }
    var label = root.querySelector(".wpsubs-adv-select__label");
    var item = root.querySelector('.wpsubs-adv-select__item[data-value="' + value + '"]');
    if (label) {
      label.textContent = item ? item.textContent.trim() : root.getAttribute("data-placeholder") || "";
    }
  }

  /**
   * Collect all [data-subscrpt-field] values within a scope into a flat map.
   *
   * @param {HTMLElement} scope Container (the term modal).
   * @return {Object}
   */
  function collectFields(scope) {
    var out = {};
    scope.querySelectorAll("[data-subscrpt-field]").forEach(function (el) {
      var key = el.getAttribute("data-subscrpt-field");
      if (el.classList.contains("wpsubs-adv-select")) {
        out[key] = advValue(el);
      } else if (el.type === "checkbox") {
        out[key] = el.checked;
      } else if (el.type === "radio") {
        if (el.checked) {
          out[key] = el.value;
        }
      } else {
        out[key] = el.value;
      }
    });
    return out;
  }

  /**
   * Map the term modal's flat fields to a /terms payload.
   *
   * @param {Object} f         Flat field map.
   * @param {string} groupId   Plan group id.
   * @param {string} groupType Plan group type key.
   * @return {Object} REST payload.
   */
  function termPayload(f, groupId, groupType) {
    var data = {
      free_trial_interval: f.free_trial_interval || "day",
    };

    // Recurring Delivery: separate delivery schedule + sync toggle.
    if (typeof f.delivery_sync !== "undefined") {
      data.delivery_sync = !!f.delivery_sync;
      if (data.delivery_sync && typeof f.delivery_day !== "undefined") {
        data.delivery_day = f.delivery_day;
      }
    }
    if (typeof f.delivery_frequency !== "undefined") {
      var deliveryFreq = parseInt(f.delivery_frequency, 10);
      if (!String(f.delivery_frequency).trim() || isNaN(deliveryFreq) || deliveryFreq < 1) {
        // Empty delivery schedule -> mirror the billing schedule.
        data.delivery_frequency = parseInt(f.billing_frequency, 10) || 1;
        data.delivery_interval = f.billing_interval || "month";
      } else {
        data.delivery_frequency = deliveryFreq;
        data.delivery_interval = f.delivery_interval || "month";
      }
    }

    // Split Payment: number of payments + access-ends timing.
    if (typeof f.installment_count !== "undefined") {
      data.installment_count = Math.max(2, parseInt(f.installment_count, 10) || 2);
    }
    if (typeof f.access_ends !== "undefined") {
      data.access_ends = f.access_ends || "lifetime";
      if ("custom" === data.access_ends) {
        data.access_custom_value = parseInt(f.access_custom_value, 10) || 1;
        data.access_custom_interval = f.access_custom_interval || "month";
      }
    }

    return {
      plan_group_id: parseInt(groupId, 10),
      type: groupType || "recurring",
      title: f.title || "",
      billing_frequency: parseInt(f.billing_frequency, 10) || 1,
      billing_interval: INTERVAL_TO_INT[f.billing_interval] || 3,
      billing_length: parseInt(f.billing_length, 10) || 0,
      free_trial: f.free_trial || "",
      signup_fee: { amount: f.signup_fee_amount || "" },
      status: "active",
      data: data,
    };
  }

  /**
   * Prefill the term modal from a fetched term, or clear it for "add".
   *
   * @param {HTMLElement} modal Term modal.
   * @param {Object|null} term  Term row, or null to reset.
   */
  function fillTermModal(modal, term) {
    modal.querySelectorAll("[data-subscrpt-field]").forEach(function (el) {
      var key = el.getAttribute("data-subscrpt-field");
      var adv = el.classList.contains("wpsubs-adv-select");

      // Reset to defaults for "add".
      if (!term) {
        if (adv) {
          setAdvSelect(el, el.getAttribute("data-default-value") || "");
        } else if (el.type === "checkbox") {
          el.checked = false;
        } else if (el.type !== "radio") {
          el.value = key === "billing_frequency" || key === "billing_length" ? el.defaultValue : "";
        }
        return;
      }

      var data = term.data || {};
      switch (key) {
        case "title":
          el.value = term.title || "";
          break;
        case "billing_frequency":
          el.value = term.billing_frequency || 1;
          break;
        case "billing_interval":
          setAdvSelect(el, INT_TO_INTERVAL[term.billing_interval] || "month");
          break;
        case "billing_length":
          el.value = term.billing_length || 0;
          break;
        case "free_trial":
          el.value = term.free_trial || "";
          break;
        case "free_trial_interval":
          setAdvSelect(el, data.free_trial_interval || "day");
          break;
        case "signup_fee_amount":
          el.value = term.signup_fee && term.signup_fee.amount ? term.signup_fee.amount : "";
          break;
        case "delivery_sync":
          el.checked = !!data.delivery_sync;
          break;
        case "delivery_day":
          setAdvSelect(el, typeof data.delivery_day !== "undefined" ? data.delivery_day : "1");
          break;
        case "delivery_frequency":
          el.value = data.delivery_frequency || 1;
          break;
        case "delivery_interval":
          setAdvSelect(el, data.delivery_interval || "month");
          break;
        case "installment_count":
          el.value = data.installment_count || 2;
          break;
        case "access_ends":
          setAdvSelect(el, data.access_ends || "lifetime");
          break;
        case "access_custom_value":
          el.value = data.access_custom_value || 1;
          break;
        case "access_custom_interval":
          setAdvSelect(el, data.access_custom_interval || "month");
          break;
        default:
          break;
      }
    });
    modal.setAttribute("data-editing", term ? term.id : "");
    toggleAccessCustom(modal);
    toggleDeliveryDay(modal);
    var title = modal.querySelector("[data-subscrpt-term-title]");
    if (title) {
      title.textContent = term ? i18n.editTerm || "Edit Duration" : i18n.addTerm || "Add Duration";
    }
  }

  /**
   * Show the custom access-length inputs only when "Custom" is selected.
   *
   * @param {HTMLElement} modal Term modal (or any ancestor of the controls).
   */
  function toggleAccessCustom(modal) {
    if (!modal) {
      return;
    }
    var sel = modal.querySelector('[data-subscrpt-field="access_ends"]');
    var custom = modal.querySelector("[data-subscrpt-access-custom]");
    if (!sel || !custom) {
      return;
    }
    var isCustom = "custom" === advValue(sel);
    custom.style.display = isCustom ? "" : "none";

    // Access ends spans full width unless the custom-duration column is shown.
    var grid = modal.querySelector("[data-subscrpt-access-grid]");
    if (grid) {
      grid.style.gridTemplateColumns = isCustom ? "1fr 2fr" : "1fr";
    }
  }

  /**
   * Enable the delivery-day picker only when Synchronize schedule is on
   * (it stays visible but disabled otherwise).
   *
   * @param {HTMLElement} modal Term modal.
   */
  function toggleDeliveryDay(modal) {
    if (!modal) {
      return;
    }
    var box = modal.querySelector('[data-subscrpt-field="delivery_sync"]');
    var day = modal.querySelector("[data-subscrpt-delivery-day]");
    if (!box || !day) {
      return;
    }
    var on = box.checked;
    day.style.opacity = on ? "" : "0.55";
    day.style.pointerEvents = on ? "" : "none";
  }

  /**
   * Reset the term modal for a specific group and open it (used by callers
   * that chain "create group → add first plan", e.g. the product editor).
   *
   * @param {number|string} groupId   Plan group id.
   * @param {string}        groupType Plan group type key.
   */
  function openTermModalForGroup(groupId, groupType) {
    var modal = document.querySelector("[data-subscrpt-term-modal]");
    if (!modal) {
      return;
    }
    modal.setAttribute("data-group-id", groupId);
    modal.setAttribute("data-group-type", groupType || "recurring");
    fillTermModal(modal, null);
    openModal("subscrpt-term-modal");
  }

  /* ------------------------------------------------------------------ *
   * Handlers (delegated on document; each host page renders the modals).
   * ------------------------------------------------------------------ */

  // Plan-type cards: click to select (skips locked / Pro cards). Selection is a
  // border + soft brand background - no radio input.
  document.addEventListener("click", function (e) {
    var card = e.target.closest(".subscrpt-type-card");
    if (!card || card.hasAttribute("data-locked")) {
      return;
    }
    var list = card.closest("[data-subscrpt-type-list]");
    if (!list) {
      return;
    }
    var typeLabels = [];
    list.querySelectorAll(".subscrpt-type-card").forEach(function (c) {
      var on = c === card;
      c.classList.toggle("is-selected", on);
      c.style.borderColor = on ? "var(--wpsubs-brand)" : "var(--wpsubs-border)";
      c.style.background = on ? "var(--wpsubs-brand-light)" : "";
      var icon = c.querySelector(".dashicons");
      if (icon) {
        icon.style.color = on ? "var(--wpsubs-brand)" : "var(--wpsubs-text-subtle)";
      }
      var lbl = c.getAttribute("data-subscrpt-type-label");
      if (lbl) {
        typeLabels.push(lbl);
      }
    });

    // Auto-fill the name from the selected type — but only while it is empty or
    // still holds an auto-generated type label, so a name the user typed (or a
    // restored one) is never overwritten.
    var modal = card.closest(".wpsubs-modal");
    var nameInput = modal ? modal.querySelector("#subscrpt-create-name") : null;
    var selectedLabel = card.getAttribute("data-subscrpt-type-label");
    if (nameInput && selectedLabel) {
      var current = nameInput.value.trim();
      if (current === "" || typeLabels.indexOf(current) !== -1) {
        nameInput.value = selectedLabel;
      }
    }
  });

  // Create a plan group. On success, close the modal and let the host decide
  // where to go next (Plans page redirects to the detail; the product editor
  // chains into the Add-Selling-Plan modal).
  document.addEventListener("click", function (e) {
    var btn = e.target.closest("[data-subscrpt-create-plan]");
    if (!btn) {
      return;
    }
    e.preventDefault();

    var modal = btn.closest(".wpsubs-modal");
    var nameInput = modal.querySelector("#subscrpt-create-name");
    var name = nameInput ? nameInput.value.trim() : "";

    if (!name) {
      save.notify(i18n.nameRequired, "error");
      return;
    }

    // Selected type card (defaults to recurring). The REST controller rejects
    // non-recurring types unless Pro is active.
    var selectedCard = modal.querySelector(".subscrpt-type-card.is-selected");
    var planType = selectedCard ? selectedCard.getAttribute("data-subscrpt-type") : "recurring";

    // Deferred mode (product-editor wizard): don't create yet. Hand the group
    // details to step 2, which creates the group + first plan atomically on
    // submit, so nothing is created until both steps are filled.
    if (modal.hasAttribute("data-subscrpt-defer")) {
      closeModal("subscrpt-create-plan");
      document.dispatchEvent(
        new CustomEvent("subscrpt:group-step", {
          detail: { title: name, type: planType || "recurring" },
        }),
      );
      return;
    }

    setLoading(btn, true);
    api("POST", "/groups", {
      title: name,
      type: planType || "recurring",
      product_type: 1,
      status: "active",
    })
      .then(function (group) {
        setLoading(btn, false);
        if (nameInput) {
          nameInput.value = "";
        }
        closeModal("subscrpt-create-plan");
        document.dispatchEvent(new CustomEvent("subscrpt:group-created", { detail: { group: group } }));
      })
      .catch(function (err) {
        setLoading(btn, false);
        save.notify(err.message || i18n.genericError, "error");
      });
  });

  // Re-evaluate the custom access row when the selection changes.
  document.addEventListener("wpsubs:select", function (e) {
    var root = e.target.closest('[data-subscrpt-field="access_ends"]');
    if (root) {
      toggleAccessCustom(root.closest("[data-subscrpt-term-modal]"));
    }
  });

  // Toggle the delivery-day picker as the sync checkbox changes.
  document.addEventListener("change", function (e) {
    var box = e.target.closest('[data-subscrpt-field="delivery_sync"]');
    if (box) {
      toggleDeliveryDay(box.closest("[data-subscrpt-term-modal]"));
    }
  });

  // Open the term modal in add or edit mode.
  document.addEventListener("click", function (e) {
    var add = e.target.closest("[data-subscrpt-add-term]");
    var edit = e.target.closest("[data-subscrpt-edit-term]");
    if (!add && !edit) {
      return;
    }
    var modal = document.querySelector("[data-subscrpt-term-modal]");
    if (!modal) {
      return;
    }
    if (add) {
      fillTermModal(modal, null);
      // The trigger already carries data-wpsubs-modal-open, so the shared
      // WPSubsModal opens it.
      return;
    }
    e.preventDefault();
    var termId = edit.getAttribute("data-subscrpt-edit-term");
    api("GET", "/terms/" + termId)
      .then(function (term) {
        fillTermModal(modal, term);
        openModal("subscrpt-term-modal");
      })
      .catch(function (err) {
        save.notify(err.message || i18n.genericError, "error");
      });
  });

  // Save the term (create or update). On success, close the modal and let the
  // host decide (Plans page reloads; the product editor refreshes in place).
  document.addEventListener("click", function (e) {
    var btn = e.target.closest("[data-subscrpt-term-submit]");
    if (!btn) {
      return;
    }
    var modal = btn.closest("[data-subscrpt-term-modal]");
    var groupId = modal.getAttribute("data-group-id");
    var groupType = modal.getAttribute("data-group-type");
    var editing = modal.getAttribute("data-editing");
    var payload = termPayload(collectFields(modal), groupId, groupType);

    if (!payload.title) {
      save.notify(i18n.nameRequired, "error");
      return;
    }

    // Deferred mode (product-editor wizard): hand the plan payload to the host,
    // which creates the group + this plan atomically. Nothing is written here.
    if (modal.hasAttribute("data-subscrpt-defer")) {
      closeModal("subscrpt-term-modal");
      document.dispatchEvent(new CustomEvent("subscrpt:term-step", { detail: { payload: payload } }));
      return;
    }

    setLoading(btn, true);
    var req = editing ? api("PUT", "/terms/" + editing, payload) : api("POST", "/terms", payload);
    req
      .then(function (term) {
        setLoading(btn, false);
        closeModal("subscrpt-term-modal");
        document.dispatchEvent(
          new CustomEvent("subscrpt:term-saved", {
            detail: { groupId: groupId, termId: term && term.id, editing: editing },
          }),
        );
      })
      .catch(function (err) {
        setLoading(btn, false);
        save.notify(err.message || i18n.genericError, "error");
      });
  });

  window.WPSubsPlanForms = {
    api: api,
    seedOneTimePrices: seedOneTimePrices,
    fillTermModal: fillTermModal,
    openModal: openModal,
    closeModal: closeModal,
    openTermModalForGroup: openTermModalForGroup,
  };
})();

```
