/** * Subscription Plans - admin interactions (free base). * * Relies on the shared components in assets/js/admin-components/: * - WPSubsModal - data-wpsubs-modal-open / -close, fires wpsubs:modal:open. * - WPSubsTabs - the detail page's Durations / Products tabs. * - WPSubsAccordion - the term list + term-modal sections. * * Everything here is the list/detail chrome plus REST CRUD for plan groups and * durations (terms). All writes go through wpsubscription/v1. The Products * tab is read-only in free, so no product attach/price wiring lives here. */ (function () { "use strict"; var cfg = window.subscrptPlans || {}; var i18n = cfg.i18n || {}; // REST calls, the button-busy lock and every message on this screen 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; /* ------------------------------------------------------------------ * * Row-actions dropdown (kebab) + client-side list filter. * ------------------------------------------------------------------ */ document.addEventListener("click", function (e) { var trigger = e.target.closest("[data-subscrpt-dropdown] .wpsubs-row-actions__trigger"); if (trigger) { e.preventDefault(); var wrap = trigger.closest("[data-subscrpt-dropdown]"); var menu = wrap.querySelector(".wpsubs-dropdown"); var opening = menu && !menu.classList.contains("wpsubs-dropdown--open"); closeDropdowns(); if (menu && opening) { menu.hidden = false; menu.classList.add("wpsubs-dropdown--open"); wrap.classList.add("wpsubs-row-actions--open"); } return; } // A click on a menu item (or anywhere outside the menu) closes the dropdown. if (!e.target.closest(".wpsubs-dropdown") || e.target.closest(".wpsubs-dropdown__item")) { closeDropdowns(); } }); /** * Close every open row-actions dropdown. */ function closeDropdowns() { document.querySelectorAll(".wpsubs-row-actions--open").forEach(function (wrap) { wrap.classList.remove("wpsubs-row-actions--open"); var menu = wrap.querySelector(".wpsubs-dropdown"); if (menu) { menu.classList.remove("wpsubs-dropdown--open"); menu.hidden = true; } }); } // Whole plan-group row opens its detail page — except clicks on a link, // button, or the actions menu, which keep their own behaviour. document.addEventListener("click", function (e) { var row = e.target.closest(".wpsubs-plan-row[data-href]"); if (!row || e.target.closest("a, button, input, label, .wpsubs-row-actions, .wpsubs-col--check")) { return; } window.location.href = row.getAttribute("data-href"); }); /* ------------------------------------------------------------------ * * Plan group: create + delete. * ------------------------------------------------------------------ */ // Plan-group create + the Add / Edit Duration modal now live in the shared // plan-forms.js module (window.WPSubsPlanForms). It dispatches events on // success so this page navigates accordingly. // A new plan group was created: open its detail page. document.addEventListener("subscrpt:group-created", function (e) { var group = e.detail && e.detail.group; if (group && group.id) { window.location.href = cfg.listUrl + "&view=detail&plan=" + group.id; } }); // A duration (term) was created or updated: reload to show it. document.addEventListener("subscrpt:term-saved", function () { window.location.reload(); }); document.addEventListener("click", function (e) { var link = e.target.closest("[data-subscrpt-delete-plan]"); if (!link) { return; } e.preventDefault(); if (!window.confirm(i18n.confirmPlan)) { return; } var id = link.getAttribute("data-subscrpt-delete-plan"); api("DELETE", "/groups/" + id) .then(function () { var row = link.closest(".wpsubs-plan-row"); if (row) { row.parentNode.removeChild(row); } else { window.location.href = cfg.listUrl; } }) .catch(function (err) { save.notify(err.message || i18n.genericError, "error"); }); }); /* ------------------------------------------------------------------ * * Tab-scoped header actions (Add Duration). * ------------------------------------------------------------------ */ /** * Show a header action only while the tab it belongs to is selected. The * button sits beside the tab list rather than inside a panel, so it does not * hide with the panel and has to follow the selection itself. */ function syncTabActions() { document.querySelectorAll("[data-subscrpt-tab-action]").forEach(function (action) { var tab = document.getElementById(action.getAttribute("data-subscrpt-tab-action")); action.style.display = tab && "true" === tab.getAttribute("aria-selected") ? "" : "none"; }); } document.addEventListener("wpsubs:tab:change", syncTabActions); // WPSubsTabs activates the initial tab on DOMContentLoaded; match it either // way so the action is never left showing on the wrong tab. if ("loading" === document.readyState) { document.addEventListener("DOMContentLoaded", syncTabActions); } else { syncTabActions(); } /* ------------------------------------------------------------------ * * Plan group: inline rename of the detail page title. * ------------------------------------------------------------------ */ /** * The rename widget's parts, or null when not on the detail page. * * @return {?Object} { wrap, display, pencil, form, input, save }. */ function renameParts() { var wrap = document.querySelector("[data-subscrpt-rename]"); if (!wrap) { return null; } return { wrap: wrap, display: wrap.querySelector("[data-subscrpt-rename-display]"), pencil: wrap.querySelector("[data-subscrpt-rename-open]"), form: wrap.querySelector("[data-subscrpt-rename-form]"), input: wrap.querySelector("[data-subscrpt-rename-input]"), save: wrap.querySelector("[data-subscrpt-rename-save]"), }; } /** * Swap the title between its read and edit state. * * @param {boolean} editing Whether to show the input. */ function renameToggle(editing) { var parts = renameParts(); if (!parts) { return; } parts.display.style.display = editing ? "none" : ""; parts.pencil.style.display = editing ? "none" : ""; parts.form.style.display = editing ? "inline-flex" : "none"; if (editing) { parts.input.value = parts.display.textContent.trim(); parts.input.focus(); parts.input.select(); } } /** * Mirror the server-side breadcrumb label so the trail matches the new name * without a reload - 30 characters, ellipsis, full text as the title * attribute (see subscrpt_truncate_text()). * * @param {string} name New plan name. */ function renameBreadcrumb(name) { var crumb = document.querySelector(".wp-subscription-breadcrumb-current"); if (!crumb) { return; } var chars = Array.from(name); var label = chars.length > 30 ? chars.slice(0, 30).join("") + "\u2026" : name; crumb.textContent = label; if (label === name) { crumb.removeAttribute("title"); } else { crumb.setAttribute("title", name); } } /** * Persist the new plan name, then update the heading and breadcrumb in place. */ function renameSave() { var parts = renameParts(); if (!parts) { return; } var host = parts.wrap.closest("[data-plan-id]"); var name = parts.input.value.trim(); if (!name) { save.notify(i18n.nameRequired, "error"); parts.input.focus(); return; } if (!host || name === parts.display.textContent.trim()) { renameToggle(false); return; } setLoading(parts.save, true); api("PUT", "/groups/" + host.getAttribute("data-plan-id"), { title: name }) .then(function () { parts.display.textContent = name; renameBreadcrumb(name); renameToggle(false); save.notify(i18n.saved); }) .catch(function (err) { save.notify(err.message || i18n.genericError, "error"); }) .then(function () { setLoading(parts.save, false); }); } document.addEventListener("click", function (e) { if (e.target.closest("[data-subscrpt-rename-open]")) { e.preventDefault(); renameToggle(true); return; } if (e.target.closest("[data-subscrpt-rename-cancel]")) { e.preventDefault(); renameToggle(false); return; } if (e.target.closest("[data-subscrpt-rename-save]")) { e.preventDefault(); renameSave(); } }); // Enter commits the rename, Escape abandons it. document.addEventListener("keydown", function (e) { if (!e.target.matches || !e.target.matches("[data-subscrpt-rename-input]")) { return; } if ("Enter" === e.key) { e.preventDefault(); renameSave(); } else if ("Escape" === e.key) { e.preventDefault(); renameToggle(false); } }); /* ------------------------------------------------------------------ * * Bulk actions on plan groups (select-all + per-row + Apply). * ------------------------------------------------------------------ */ /** * The per-row checkboxes currently visible (search + pagination hide rows via * display:none). Bulk actions and select-all operate on the visible set only, * so a filtered-out row is never silently deleted. * * @return {HTMLElement[]} */ function bulkRowChecks() { return Array.prototype.slice.call(document.querySelectorAll(".wpsubs-row-check")).filter(function (cb) { var tr = cb.closest("tr"); return tr && "none" !== tr.style.display; }); } /** @return {HTMLElement[]} The checked (and visible) per-row checkboxes. */ function bulkChecked() { return bulkRowChecks().filter(function (cb) { return cb.checked; }); } /** Sync the select-all checkbox (checked / indeterminate) with the visible rows. */ function bulkSync() { var all = bulkRowChecks(); var checked = bulkChecked(); var selectAll = document.querySelector("[data-subscrpt-select-all]"); if (selectAll) { selectAll.checked = all.length > 0 && checked.length === all.length; selectAll.indeterminate = checked.length > 0 && checked.length < all.length; } } /** Reset the bulk-action adv-select back to its placeholder. */ function bulkResetSelect() { var root = document.querySelector("[data-subscrpt-bulk-select]"); if (!root) { return; } var input = root.querySelector('input[type="hidden"]'); var label = root.querySelector(".wpsubs-adv-select__label"); if (input) { input.value = root.dataset.defaultValue || ""; } if (label) { label.textContent = root.dataset.placeholder || ""; } } /** * Run a bulk action over the currently-checked plan groups. * * @param {string} action The chosen action value (e.g. "delete"). */ function bulkRun(action) { if (!action) { return; } var ids = bulkChecked().map(function (cb) { return cb.value; }); if (!ids.length) { save.notify(i18n.selectPlans || i18n.genericError, "error"); return; } if ("delete" === action) { var msg = (i18n.confirmBulkDelete || "").replace("%d", ids.length); if (!window.confirm(msg)) { return; } Promise.all( ids.map(function (id) { return api("DELETE", "/groups/" + id); }), ) .then(function () { window.location.reload(); }) .catch(function (err) { save.notify(err.message || i18n.genericError, "error"); }); } } // Select-all toggles every row checkbox. document.addEventListener("change", function (e) { if (!e.target.closest("[data-subscrpt-select-all]")) { return; } var on = e.target.checked; bulkRowChecks().forEach(function (cb) { cb.checked = on; }); bulkSync(); }); // A single row checkbox changed. document.addEventListener("change", function (e) { if (e.target.classList && e.target.classList.contains("wpsubs-row-check")) { bulkSync(); } }); // Picking an option from the bulk-action dropdown runs it immediately, then // resets the dropdown back to its placeholder. document.addEventListener("wpsubs:select", function (e) { if (!e.target.closest || !e.target.closest("[data-subscrpt-bulk-select]")) { return; } var action = e.detail && e.detail.value; bulkResetSelect(); bulkRun(action); }); bulkSync(); /* ------------------------------------------------------------------ * * Durations (terms): create / edit / delete / toggle. * ------------------------------------------------------------------ */ // Delete a term. document.addEventListener("click", function (e) { var btn = e.target.closest("[data-subscrpt-delete-term]"); if (!btn) { return; } if (!window.confirm(i18n.confirmTerm)) { return; } var id = btn.getAttribute("data-subscrpt-delete-term"); api("DELETE", "/terms/" + id) .then(function () { var item = btn.closest("[data-term-id]"); if (item) { item.parentNode.removeChild(item); } }) .catch(function (err) { save.notify(err.message || i18n.genericError, "error"); }); }); /** * Lock a term's toggle while its write is in flight. The switch keeps * showing the state it is moving to, dimmed, so the click reads as accepted * rather than ignored. * * @param {HTMLElement} label The toggle's label. * @param {boolean} busy Busy state. */ function setTermBusy(label, busy) { label.setAttribute("aria-busy", busy ? "true" : "false"); label.classList.toggle("wpsubs-toggle-busy", busy); } /** * Reflect a saved status on the row: the action the toggle now offers, its * tooltip, and the Draft badge beside the name. * * @param {HTMLElement} label The toggle's label. * @param {boolean} active Whether the term is now active. */ function applyTermStatus(label, active) { label.setAttribute("data-subscrpt-set-term-status", active ? "draft" : "active"); label.setAttribute("title", active ? i18n.setDraft : i18n.setActive); var card = label.closest(".wpsubs-table-card"); var badge = card && card.querySelector("[data-subscrpt-term-badge]"); if (active) { if (badge) { badge.parentNode.removeChild(badge); } return; } if (badge || !card) { return; } var row = card.querySelector("[data-subscrpt-term-name-row]"); if (!row) { return; } badge = document.createElement("span"); badge.className = "wpsubs-badge wpsubs-badge--draft"; badge.setAttribute("data-subscrpt-term-badge", ""); badge.textContent = i18n.draft; row.appendChild(badge); } // Set a term active/draft from its row toggle. The row is updated in place // rather than reloaded: a full reload for a one-field write left the switch // sitting on its old state for the whole round trip, which read as a dead // control. document.addEventListener("click", function (e) { var label = e.target.closest("[data-subscrpt-set-term-status]"); if (!label) { return; } // The label owns the checkbox, so let it drive the switch itself. e.preventDefault(); if ("true" === label.getAttribute("aria-busy")) { return; } var id = label.getAttribute("data-term-id"); var status = label.getAttribute("data-subscrpt-set-term-status"); var active = "active" === status; var cb = label.querySelector(".wpsubs-toggle"); setTermBusy(label, true); if (cb) { cb.checked = active; } api("PUT", "/terms/" + id, { status: status }) .then(function () { applyTermStatus(label, active); save.notify(active ? i18n.termActivated : i18n.termDrafted); }) .catch(function (err) { if (cb) { cb.checked = !active; } save.notify(err.message || i18n.genericError, "error"); }) .then(function () { setTermBusy(label, false); }); }); /* ------------------------------------------------------------------ * * Products tab: refresh the panel in place. * ------------------------------------------------------------------ */ /** * Re-render the Products panel from the server, keeping the page where it * was. Attaching, detaching and repricing all change server-formatted values * (money, badges, which products exist), so the fragment is fetched rather * than patched by hand — but a full reload for it threw the whole screen * away, which is what made every one of these actions flash. * * Which products were expanded and the scroll position are restored, so the * panel comes back looking like it never moved. * * @param {string|number} groupId Plan group id. * @return {Promise} */ function refreshProducts(groupId) { var panel = document.getElementById("subscrpt-panel-products"); if (!panel || !groupId) { return Promise.resolve(); } var scrollY = window.scrollY; var open = []; panel.querySelectorAll("[data-pid]").forEach(function (item) { var header = item.querySelector(".wpsubs-accordion__header"); if (header && "true" === header.getAttribute("aria-expanded")) { open.push(item.getAttribute("data-pid")); } }); return api("GET", "/group-products/" + groupId).then(function (res) { panel.innerHTML = (res && res.html) || ""; // Components bind on DOMContentLoaded; new markup needs a nudge. if (window.WPSubsAccordion && window.WPSubsAccordion.init) { window.WPSubsAccordion.init(panel); } open.forEach(function (pid) { var item = panel.querySelector('[data-pid="' + pid + '"]'); var header = item && item.querySelector(".wpsubs-accordion__header"); if (header && "true" !== header.getAttribute("aria-expanded")) { header.click(); } }); window.scrollTo(0, scrollY); }); } /** * The plan group this detail page is showing. * * @return {string} Group id, or "" off the detail page. */ function currentGroupId() { var host = document.querySelector("[data-plan-id]"); return host ? host.getAttribute("data-plan-id") || "" : ""; } /* ------------------------------------------------------------------ * * Products tab: bulk-set one price across products x durations. * ------------------------------------------------------------------ */ /** * Reflect a variable product's variations in its parent control checkbox: * on when all are ticked, indeterminate when some are. * * @param {HTMLElement} group The [data-subscrpt-bulk-group] wrapper. */ function syncBulkParent(group) { var parent = group && group.querySelector("[data-subscrpt-bulk-parent]"); if (!parent) { return; } var kids = group.querySelectorAll("[data-subscrpt-bulk-product]"); var checked = 0; kids.forEach(function (cb) { if (cb.checked) { checked += 1; } }); parent.checked = kids.length > 0 && checked === kids.length; parent.indeterminate = checked > 0 && checked < kids.length; } /** * Update the "N selected" tally beside each column header. * * @param {HTMLElement} modal The bulk-price modal. */ function syncBulkCounts(modal) { [ ["term", "[data-subscrpt-bulk-term]"], ["product", "[data-subscrpt-bulk-product]"], ].forEach(function (pair) { var out = modal.querySelector('[data-subscrpt-bulk-count="' + pair[0] + '"]'); if (!out) { return; } var n = modal.querySelectorAll(pair[1] + ":checked").length; out.textContent = n ? (i18n.bulkSelected || "%d selected").replace("%d", n) : ""; }); } // Any change inside the modal refreshes the per-column tallies. document.addEventListener("change", function (e) { var modal = e.target.closest("[data-subscrpt-bulk-price]"); if (modal) { syncBulkCounts(modal); } }); // A column's "All" checkbox ticks/unticks every row in that column. document.addEventListener("change", function (e) { var all = e.target.closest("[data-subscrpt-bulk-toggle-all]"); var modal = all && all.closest("[data-subscrpt-bulk-price]"); if (!modal) { return; } var col = all.getAttribute("data-subscrpt-bulk-toggle-all"); var sel = "product" === col ? "[data-subscrpt-bulk-product]" : "[data-subscrpt-bulk-term]"; modal.querySelectorAll(sel).forEach(function (cb) { if (!cb.disabled) { cb.checked = all.checked; } }); if ("product" === col) { modal.querySelectorAll("[data-subscrpt-bulk-parent]").forEach(function (parent) { parent.checked = all.checked; parent.indeterminate = false; }); } }); // A variable product's parent control ticks/unticks all its variations. document.addEventListener("change", function (e) { var parent = e.target.closest("[data-subscrpt-bulk-parent]"); var group = parent && parent.closest("[data-subscrpt-bulk-group]"); if (!group || !parent.closest("[data-subscrpt-bulk-price]")) { return; } group.querySelectorAll("[data-subscrpt-bulk-product]").forEach(function (cb) { if (!cb.disabled) { cb.checked = parent.checked; } }); parent.indeterminate = false; }); // A variation tick keeps its parent control in sync. document.addEventListener("change", function (e) { var child = e.target.closest("[data-subscrpt-bulk-product]"); var group = child && child.closest("[data-subscrpt-bulk-group]"); if (!group || !child.closest("[data-subscrpt-bulk-price]")) { return; } syncBulkParent(group); }); // Apply the entered price to every selected product on every selected duration. document.addEventListener("click", function (e) { var btn = e.target.closest("[data-subscrpt-bulk-apply]"); var modal = btn && btn.closest("[data-subscrpt-bulk-price]"); if (!modal) { return; } var regInput = modal.querySelector("[data-subscrpt-bulk-regular]"); var saleInput = modal.querySelector("[data-subscrpt-bulk-sale]"); var regular = regInput ? regInput.value.trim() : ""; if ("" === regular) { save.notify(i18n.bulkNoPrice || i18n.genericError, "error"); return; } var products = Array.prototype.map.call( modal.querySelectorAll("[data-subscrpt-bulk-product]:checked"), function (cb) { return { oid: cb.getAttribute("data-oid"), vid: cb.getAttribute("data-vid") || 0 }; }, ); if (!products.length) { save.notify(i18n.bulkNoProducts || i18n.genericError, "error"); return; } var planIds = Array.prototype.map.call(modal.querySelectorAll("[data-subscrpt-bulk-term]:checked"), function (cb) { return cb.value; }); if (!planIds.length) { save.notify(i18n.bulkNoTerms || i18n.genericError, "error"); return; } setLoading(btn, true); api("POST", "/bulk-price", { plan_ids: planIds, products: products, regular_price: regular, sale_price: saleInput ? saleInput.value.trim() : "", }) .then(function () { // Close before the panel refresh replaces (and orphans) the modal — // otherwise its body-scroll lock is never released. if (window.WPSubsModal && window.WPSubsModal.close) { window.WPSubsModal.close("subscrpt-bulk-price"); } return refreshProducts(currentGroupId()); }) .then(function () { setLoading(btn, false); save.notify(i18n.bulkApplied || i18n.pricesSaved); }) .catch(function (err) { setLoading(btn, false); save.notify(err.message || i18n.genericError, "error"); }); }); /* ------------------------------------------------------------------ * * Products tab (Pro): bulk-add products to the plan group. * ------------------------------------------------------------------ */ /** * Set a variable parent's checkbox from its children's state. * * @param {HTMLElement} parentCb Parent control checkbox. * @param {HTMLElement[]} childBoxes Variation checkboxes. */ function syncParent(parentCb, childBoxes) { var all = childBoxes.every(function (c) { return c.checked; }); var none = childBoxes.every(function (c) { return !c.checked; }); parentCb.checked = all; parentCb.indeterminate = !all && !none; } /** * Fetch the group's product relations as a Set of "oid:vid" keys, so the * picker can pre-check what's already attached. * * @param {number|string} groupId Plan group id. * @return {Promise} */ function fetchAttached(groupId) { return api("GET", "/groups/" + groupId) .then(function (group) { var set = new Set(); ((group && group.plans) || []).forEach(function (term) { (term.relations || []).forEach(function (rel) { if (1 === parseInt(rel.type, 10)) { set.add(parseInt(rel.oid, 10) + ":" + (parseInt(rel.vid, 10) || 0)); } }); }); return set; }) .catch(function () { return new Set(); }); } /** * Build one picker row: checkbox, thumbnail, name, price. * * Attachable rows (simple products, variations) carry data-oid / data-vid / * data-price on the checkbox. A "control" row (variable parent) has none — * it only toggles its children and is skipped when attaching. * * @param {Object} p Product/variation row from the API. * @param {Object} opts { indent, control, parentId }. * @return {{ li: HTMLElement, cb: HTMLElement }} */ function productRow(p, opts) { opts = opts || {}; var li = document.createElement("li"); li.style.cssText = "margin:0;display:flex;align-items:center;gap:10px;padding:8px 4px;padding-left:" + (opts.indent ? "36px" : "4px") + ";border-bottom:1px solid rgba(0,0,0,0.06);"; var label = document.createElement("label"); label.style.cssText = "display:flex;align-items:center;gap:10px;flex:1 1 auto;min-width:0;cursor:pointer;"; // A product already attached to a DIFFERENT plan group can't be selected — a // product belongs to a single group. var inOtherGroup = p.plan_group_id && String(p.plan_group_id) !== String(opts.currentGroup || ""); var cb = document.createElement("input"); cb.type = "checkbox"; cb.className = "wpsubs-checkbox"; if (inOtherGroup) { cb.disabled = true; } if (!opts.control) { var oid = opts.parentId ? opts.parentId : p.id; var vid = opts.parentId ? p.id : 0; cb.setAttribute("data-oid", oid); cb.setAttribute("data-vid", vid); cb.setAttribute("data-price", p.price || ""); // Pre-check rows already attached to this group (never one locked to another). if (!inOtherGroup && opts.attached && opts.attached.has(oid + ":" + vid)) { cb.checked = true; } } label.appendChild(cb); var thumb = document.createElement("span"); thumb.style.cssText = "flex:0 0 auto;width:32px;height:32px;border-radius:4px;background:var(--wpsubs-bg-subtle,#f1f2f4);border:1px solid var(--wpsubs-border);overflow:hidden;display:flex;align-items:center;justify-content:center;"; if (p.image) { var img = document.createElement("img"); img.src = p.image; img.alt = ""; img.style.cssText = "width:100%;height:100%;object-fit:cover;display:block;"; thumb.appendChild(img); } else { thumb.innerHTML = ''; } label.appendChild(thumb); var name = document.createElement("span"); name.textContent = p.name; name.style.cssText = "font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"; label.appendChild(name); if (inOtherGroup) { li.style.opacity = "0.55"; label.style.cursor = "not-allowed"; li.title = (i18n.usedInPlan || "This product is being used in '%s'").replace("%s", p.plan_group_name || ""); var note = document.createElement("span"); note.textContent = i18n.inOtherPlan || "Added to another plan"; note.style.cssText = "flex:0 0 auto;font-size:11px;font-style:italic;color:var(--wpsubs-text-subtle);"; label.appendChild(note); } var price = document.createElement("span"); price.style.cssText = "color:var(--wpsubs-text-muted);font-size:13px;flex:0 0 auto;"; price.textContent = p.price_html || ""; li.appendChild(label); li.appendChild(price); return { li: li, cb: cb }; } /** * Populate the Add Products picker with a product search. * * @param {HTMLElement} modal The add-product modal. * @param {string} search Search term. */ function loadProducts(modal, search) { var list = modal.querySelector("[data-subscrpt-product-list]"); if (!list) { return; } var attached = modal._attached || new Set(); var currentGroup = modal.getAttribute("data-group-id") || ""; list.innerHTML = '
  • ' + (i18n.loading || "Loading…") + "
  • "; api("GET", "/products?search=" + encodeURIComponent(search || "")) .then(function (products) { if (!products.length) { list.innerHTML = '
  • ' + (i18n.noProducts || "No products found.") + "
  • "; return; } list.innerHTML = ""; products.forEach(function (p) { var variations = p.variations || []; if (variations.length) { // Variable product: parent row is a select-all control (no own // relation), variations are the attachable rows offset beneath it. var parent = productRow(p, { indent: false, control: true, currentGroup: currentGroup }); list.appendChild(parent.li); var childBoxes = []; variations.forEach(function (v) { var child = productRow(v, { indent: true, parentId: p.id, attached: attached, currentGroup: currentGroup, }); childBoxes.push(child.cb); list.appendChild(child.li); }); // Reflect the pre-checked children in the parent control. syncParent(parent.cb, childBoxes); // Parent toggles every child; children keep the parent in sync. parent.cb.addEventListener("change", function () { childBoxes.forEach(function (cb) { cb.checked = parent.cb.checked; }); parent.cb.indeterminate = false; }); childBoxes.forEach(function (cb) { cb.addEventListener("change", function () { syncParent(parent.cb, childBoxes); }); }); } else { list.appendChild(productRow(p, { indent: false, attached: attached, currentGroup: currentGroup }).li); } }); // No divider under the last row. if (list.lastElementChild) { list.lastElementChild.style.borderBottom = "none"; } syncPickerCount(modal); syncSelectAll(modal); }) .catch(function () { list.innerHTML = '
  • ' + (i18n.genericError || "") + "
  • "; }); } /** * Update the picker's running tally. Ticking is a two-way control — it * attaches and detaches — so the count is what says how the plan will look * after saving, rather than how many were just clicked. * * @param {HTMLElement} modal The add-product modal. */ function syncPickerCount(modal) { var out = modal && modal.querySelector("[data-subscrpt-picker-count]"); if (!out) { return; } var boxes = modal.querySelectorAll("[data-subscrpt-product-list] input[data-oid]"); var n = 0; Array.prototype.forEach.call(boxes, function (box) { if (box.checked) { n += 1; } }); out.textContent = n ? (i18n.picked || "%d on this plan").replace("%d", n) : i18n.pickedNone || ""; } /** * The picker's attachable, non-locked checkboxes — what select-all acts on. * Rows locked to another plan are disabled and left out. * * @param {HTMLElement} modal The add-product modal. * @return {HTMLElement[]} Enabled row checkboxes. */ function selectableBoxes(modal) { var boxes = modal.querySelectorAll("[data-subscrpt-product-list] input[data-oid]"); return Array.prototype.filter.call(boxes, function (box) { return !box.disabled; }); } /** * Reflect the rows in the select-all checkbox: on when all are ticked, * indeterminate when some are, off when none (disabled when nothing to pick). * * @param {HTMLElement} modal The add-product modal. */ function syncSelectAll(modal) { var toggle = modal && modal.querySelector("[data-subscrpt-picker-all]"); if (!toggle) { return; } var boxes = selectableBoxes(modal); var checked = boxes.filter(function (box) { return box.checked; }).length; toggle.disabled = 0 === boxes.length; toggle.checked = boxes.length > 0 && checked === boxes.length; toggle.indeterminate = checked > 0 && checked < boxes.length; } // Any tick in the picker updates the tally and the select-all state. document.addEventListener("change", function (e) { var box = e.target.closest("[data-subscrpt-product-list] input[type=checkbox]"); var modal = box && box.closest("[data-subscrpt-add-product]"); if (modal) { syncPickerCount(modal); syncSelectAll(modal); } }); // Select-all ticks/unticks every enabled row. Select-all makes the picker // uniform, so parent controls and children all take the same state directly — // no synthetic change events (which would re-enter syncSelectAll mid-loop). document.addEventListener("change", function (e) { var toggle = e.target.closest("[data-subscrpt-picker-all]"); var modal = toggle && toggle.closest("[data-subscrpt-add-product]"); if (!modal) { return; } var checked = toggle.checked; var boxes = modal.querySelectorAll("[data-subscrpt-product-list] input[type=checkbox]"); Array.prototype.forEach.call(boxes, function (box) { if (box.disabled) { return; } box.checked = checked; box.indeterminate = false; }); toggle.checked = checked; toggle.indeterminate = false; syncPickerCount(modal); }); // Load the picker when the modal opens (pre-checking attached products). document.addEventListener("wpsubs:modal:open", function (e) { var modal = e.target; if (modal && modal.id === "subscrpt-add-product") { fetchAttached(modal.getAttribute("data-group-id")).then(function (set) { modal._attached = set; loadProducts(modal, ""); }); } }); // Debounced product search. var addProductTimer; document.addEventListener("input", function (e) { var input = e.target.closest("[data-subscrpt-product-search]"); if (!input) { return; } var modal = input.closest("[data-subscrpt-add-product]"); window.clearTimeout(addProductTimer); addProductTimer = window.setTimeout(function () { loadProducts(modal, input.value.trim()); }, 300); }); // Attach the checked products to every duration in the group. document.addEventListener("click", function (e) { var btn = e.target.closest("[data-subscrpt-add-product-submit]"); if (!btn) { return; } var modal = btn.closest("[data-subscrpt-add-product]"); var groupId = modal.getAttribute("data-group-id"); // Only attachable rows carry data-oid (variable parents are skipped). var boxes = modal.querySelectorAll("[data-subscrpt-product-list] input[data-oid]"); // Sync the group to the picker: attach checked rows, detach any previously // attached row that is now unchecked. var checkedKeys = new Set(); var checked = []; Array.prototype.forEach.call(boxes, function (box) { if (box.checked) { checked.push(box); checkedKeys.add(box.getAttribute("data-oid") + ":" + (box.getAttribute("data-vid") || "0")); } }); var attached = modal._attached || new Set(); var toRemove = new Set(); attached.forEach(function (key) { if (!checkedKeys.has(key)) { toRemove.add(key); } }); if (!checked.length && !toRemove.size) { return; } setLoading(btn, true); api("GET", "/groups/" + groupId) .then(function (group) { var terms = (group && group.plans) || []; var isInstallments = group && ("installments" === group.type_key || 3 === parseInt(group.type, 10)); var calls = []; // Attach checked rows to every term. checked.forEach(function (box) { var price = box.getAttribute("data-price") || ""; var data = isInstallments ? { price_per_installment: price, down_payment: "" } : { regular_price: price, sale_price: "" }; terms.forEach(function (term) { calls.push( api("POST", "/relations", { plan_id: term.id, oid: parseInt(box.getAttribute("data-oid"), 10), vid: parseInt(box.getAttribute("data-vid"), 10) || 0, type: 1, status: "active", data: data, }), ); }); }); // Detach rows that were unchecked. if (toRemove.size) { terms.forEach(function (term) { (term.relations || []).forEach(function (rel) { if (1 !== parseInt(rel.type, 10)) { return; } var key = parseInt(rel.oid, 10) + ":" + (parseInt(rel.vid, 10) || 0); if (toRemove.has(key)) { calls.push(api("DELETE", "/relations/" + rel.id)); } }); }); } return Promise.all(calls); }) .then(function () { if (window.WPSubsModal) { window.WPSubsModal.close(modal); } return refreshProducts(groupId); }) .then(function () { setLoading(btn, false); save.notify(i18n.productsUpdated); }) .catch(function (err) { setLoading(btn, false); save.notify(err.message || i18n.genericError, "error"); }); }); // Remove a product from the group: delete every relation for that product // (all durations, all variations). document.addEventListener("click", function (e) { var btn = e.target.closest("[data-subscrpt-remove-product]"); if (!btn) { return; } var oid = parseInt(btn.getAttribute("data-subscrpt-remove-product"), 10); var wrap = document.querySelector("[data-plan-id]"); var groupId = wrap && wrap.getAttribute("data-plan-id"); if (!groupId || !window.confirm(i18n.confirmRemoveProduct || "Remove this product from the plan?")) { return; } setLoading(btn, true); api("GET", "/groups/" + groupId) .then(function (group) { var ids = []; ((group && group.plans) || []).forEach(function (term) { (term.relations || []).forEach(function (rel) { if (1 === parseInt(rel.type, 10) && parseInt(rel.oid, 10) === oid) { ids.push(rel.id); } }); }); return Promise.all( ids.map(function (id) { return api("DELETE", "/relations/" + id); }), ); }) .then(function () { return refreshProducts(groupId); }) .then(function () { setLoading(btn, false); save.notify(i18n.productRemoved); }) .catch(function (err) { setLoading(btn, false); save.notify(err.message || i18n.genericError, "error"); }); }); // Remove a single variation from the group: delete every relation for that // (product, variation) pair across all durations. document.addEventListener("click", function (e) { var btn = e.target.closest("[data-subscrpt-remove-variation]"); if (!btn) { return; } var oid = parseInt(btn.getAttribute("data-oid"), 10); var vid = parseInt(btn.getAttribute("data-vid"), 10); var wrap = document.querySelector("[data-plan-id]"); var groupId = wrap && wrap.getAttribute("data-plan-id"); if (!groupId || !window.confirm(i18n.confirmRemoveVariation || "Remove this variation from the plan?")) { return; } setLoading(btn, true); api("GET", "/groups/" + groupId) .then(function (group) { var ids = []; ((group && group.plans) || []).forEach(function (term) { (term.relations || []).forEach(function (rel) { if (1 === parseInt(rel.type, 10) && parseInt(rel.oid, 10) === oid && parseInt(rel.vid, 10) === vid) { ids.push(rel.id); } }); }); return Promise.all( ids.map(function (id) { return api("DELETE", "/relations/" + id); }), ); }) .then(function () { return refreshProducts(groupId); }) .then(function () { setLoading(btn, false); save.notify(i18n.productRemoved); }) .catch(function (err) { setLoading(btn, false); save.notify(err.message || i18n.genericError, "error"); }); }); /* ------------------------------------------------------------------ * * Products tab (Pro): inline edit of a price card's rows. * ------------------------------------------------------------------ */ /** * Toggle a price card between read and edit mode. * * @param {HTMLElement} card The [data-subscrpt-price-card] element. * @param {boolean} editing Desired mode. */ function priceCardMode(card, editing) { card.querySelectorAll(".subscrpt-pe-view").forEach(function (el) { el.style.display = editing ? "none" : ""; }); card.querySelectorAll(".subscrpt-pe-edit").forEach(function (el) { el.style.display = editing ? "" : "none"; }); var toggle = function (sel, show) { var btn = card.querySelector(sel); if (btn) { btn.style.display = show ? "" : "none"; } }; toggle("[data-subscrpt-edit-prices]", !editing); toggle("[data-subscrpt-cancel-prices]", editing); toggle("[data-subscrpt-save-prices]", editing); } // Enter edit mode (snapshot current values for cancel). document.addEventListener("click", function (e) { var btn = e.target.closest("[data-subscrpt-edit-prices]"); if (!btn) { return; } var card = btn.closest("[data-subscrpt-price-card]"); card.querySelectorAll("[data-field]").forEach(function (field) { field.dataset.orig = "checkbox" === field.type ? (field.checked ? "1" : "") : field.value; }); priceCardMode(card, true); }); // Cancel: restore snapshot, back to read mode. document.addEventListener("click", function (e) { var btn = e.target.closest("[data-subscrpt-cancel-prices]"); if (!btn) { return; } var card = btn.closest("[data-subscrpt-price-card]"); card.querySelectorAll("[data-field]").forEach(function (field) { if ("checkbox" === field.type) { field.checked = "1" === field.dataset.orig; } else { field.value = field.dataset.orig || ""; } }); priceCardMode(card, false); }); // Save: PUT each row's regular / offer price + enabled state to its relation. document.addEventListener("click", function (e) { var btn = e.target.closest("[data-subscrpt-save-prices]"); if (!btn) { return; } var card = btn.closest("[data-subscrpt-price-card]"); var rows = card.querySelectorAll("[data-subscrpt-relation]"); // The card carries the product id, and (for a variable product) the // variation id — a variation row seeded from the product-level price has // no relation of its own yet, so Save creates one for that variation. var cardPid = card.getAttribute("data-product-id"); var cardVid = parseInt(card.getAttribute("data-variation-id"), 10) || 0; setLoading(btn, true); var calls = Array.prototype.map.call(rows, function (row) { var id = parseInt(row.getAttribute("data-subscrpt-relation"), 10) || 0; var reg = row.querySelector('[data-field="regular_price"]'); var sale = row.querySelector('[data-field="sale_price"]'); var enabled = row.querySelector('[data-field="enabled"]'); var data = { regular_price: reg ? reg.value : "", sale_price: sale ? sale.value : "", discount_value: 0, }; if (id) { return api("PUT", "/relations/" + id, { exclude: enabled ? !enabled.checked : false, data: data, }); } // No relation yet (seeded variation price) → create one for this variation. return api("POST", "/relations", { plan_id: parseInt(row.getAttribute("data-plan-id"), 10) || 0, oid: parseInt(cardPid, 10) || 0, vid: cardVid, type: 1, status: "active", exclude: enabled ? !enabled.checked : false, data: data, }); }); // One-time purchase row (product-specific native price) shares this Save. // Only persisted when its controls are rendered (Pro): the one-time price // stays restricted, so leave it untouched when those inputs are absent. var otRow = card.querySelector("[data-subscrpt-onetime-row]"); var pid = card.getAttribute("data-product-id"); var enable = otRow ? otRow.querySelector("[data-subscrpt-onetime-enable]") : null; if (otRow && pid && enable) { var vid = card.getAttribute("data-variation-id"); var otPrice = otRow.querySelector('[data-ot-field="price"]'); var otOffer = otRow.querySelector('[data-ot-field="offer"]'); var otVals = { enabled: enable.checked, price: otPrice ? otPrice.value : "", offer: otOffer ? otOffer.value : "", }; var otPayload = otVals; if (vid) { otPayload = { variations: {} }; otPayload.variations[vid] = otVals; } calls.push(api("PUT", "/product-onetime/" + pid, otPayload)); } Promise.all(calls) .then(function () { return refreshProducts(currentGroupId()); }) .then(function () { setLoading(btn, false); save.notify(i18n.pricesSaved); }) .catch(function (err) { setLoading(btn, false); save.notify(err.message || i18n.genericError, "error"); }); }); /* ------------------------------------------------------------------ * * Products tab (Pro): simple product one-time purchase card. * Variable products handle one-time per variation via the card Save above; * simple products use this standalone card + its own toggle/Save. * ------------------------------------------------------------------ */ /** * PUT a simple product's one-time purchase: the switch as it stands, plus prices. * * The endpoint writes the prices it is given and clears any it is not, so * both are always sent. * * @param {HTMLElement} card The one-time card. * @param {{price: string, offer: string}} prices Prices to store. * @return {Promise} The request. */ function putOneTime(card, prices) { var enable = card.querySelector("[data-subscrpt-onetime-enable]"); return api("PUT", "/product-onetime/" + card.getAttribute("data-product-id"), { enabled: enable ? enable.checked : false, price: prices.price, offer: prices.offer, }); } // Save the simple card's prices (Save sits in the price row). document.addEventListener("click", function (e) { var btn = e.target.closest("[data-subscrpt-onetime-save]"); if (!btn) { return; } var card = btn.closest("[data-subscrpt-onetime-card]"); var price = card.querySelector('[data-ot-field="price"]'); var offer = card.querySelector('[data-ot-field="offer"]'); setLoading(btn, true); putOneTime(card, { price: price ? price.value : "", offer: offer ? offer.value : "" }) .then(function () { return refreshProducts(currentGroupId()); }) .then(function () { setLoading(btn, false); save.notify(i18n.pricesSaved); }) .catch(function (err) { setLoading(btn, false); save.notify(err.message || i18n.genericError, "error"); }); }); // The switch saves itself. Save lives in the price row, which hides while the // switch is off, so "off" could not be saved any other way. It sends the // prices as last saved — each input's default value — because the endpoint // clears a price it is not given, and an unsaved price edit is left for Save. // No product refresh: the card already shows what was stored. document.addEventListener("change", function (e) { var toggle = e.target.closest("[data-subscrpt-onetime-card] [data-subscrpt-onetime-enable]"); if (!toggle || toggle.subscrptReverting) { return; } var card = toggle.closest("[data-subscrpt-onetime-card]"); var price = card.querySelector('[data-ot-field="price"]'); var offer = card.querySelector('[data-ot-field="offer"]'); toggle.disabled = true; putOneTime(card, { price: price ? price.defaultValue : "", offer: offer ? offer.defaultValue : "" }) .then(function () { toggle.disabled = false; save.notify(toggle.checked ? i18n.oneTimeOn : i18n.oneTimeOff); }) .catch(function (err) { toggle.disabled = false; // Put the switch back so it shows what is stored; the re-dispatched // change re-syncs the price row without saving again. toggle.subscrptReverting = true; toggle.checked = !toggle.checked; toggle.dispatchEvent(new Event("change", { bubbles: true })); toggle.subscrptReverting = false; save.notify(err.message || i18n.genericError, "error"); }); }); /* ------------------------------------------------------------------ * * Drag-and-drop reorder: durations + products (vertical only). * * A drag starts on [data-subscrpt-sort-handle] only, moves its * [data-subscrpt-sort-item] within the [data-subscrpt-sortable] list, and * saves the list's full id order (hidden/paged-out items included) to * /groups/{id}/order. Siblings slide aside with a FLIP transition. Arrow * keys on a focused handle move it one step. Everything is delegated, * because the Products panel is re-rendered in place. * ------------------------------------------------------------------ */ var SORT_ANIM_MS = 200; var SORT_EDGE_PX = 60; var sortDrag = null; var sortPending = {}; /** * Items of a list the merchant can currently see (search/pager aware). * * @param {HTMLElement} list Sortable list. * @return {HTMLElement[]} */ function sortVisible(list) { return Array.prototype.filter.call(list.children, function (el) { return el.hasAttribute("data-subscrpt-sort-item") && "none" !== el.style.display; }); } /** * Every item id of a list, in DOM order. * * @param {HTMLElement} list Sortable list. * @return {number[]} */ function sortIds(list) { return Array.prototype.map.call(list.querySelectorAll(":scope > [data-subscrpt-sort-item]"), function (el) { return parseInt(el.getAttribute("data-subscrpt-sort-item"), 10); }); } /** * Move `el` in the DOM (before `ref`, or to the end) and slide every other * visible item from where it was to where it now is. * * @param {HTMLElement} list Sortable list. * @param {HTMLElement} el Item to move. * @param {HTMLElement|null} ref Insert before this item. * @param {boolean} self Animate `el` too (keyboard moves). */ function sortMove(list, el, ref, self) { var others = sortVisible(list).filter(function (it) { return self || it !== el; }); var before = others.map(function (it) { return it.offsetTop; }); list.insertBefore(el, ref); others.forEach(function (it, i) { var delta = before[i] - it.offsetTop; if (!delta) { return; } it.style.transition = "none"; it.style.transform = "translateY(" + delta + "px)"; void it.offsetHeight; // Commit the start frame. it.style.transition = "transform " + SORT_ANIM_MS + "ms cubic-bezier(0.2, 0, 0, 1)"; it.style.transform = ""; window.clearTimeout(it._subscrptSortTimer); it._subscrptSortTimer = window.setTimeout(function () { it.style.transition = ""; }, SORT_ANIM_MS); }); } /** * Put a list back into a given id order (a failed save). * * @param {HTMLElement} list Sortable list. * @param {number[]} ids Order to restore. */ function sortRestore(list, ids) { ids.forEach(function (id) { var el = list.querySelector(':scope > [data-subscrpt-sort-item="' + id + '"]'); if (el) { list.appendChild(el); } }); sortSynced(list); } /** * Tell the list's search/pager the DOM order changed. * * @param {HTMLElement} list Sortable list. */ function sortSynced(list) { var root = list.closest("[data-subscrpt-browse]"); if (root) { root.dispatchEvent(new CustomEvent("subscrpt:sorted")); } } /** * Save a list's order. Debounced so a run of arrow-key moves is one write; * on failure the list goes back to the order it had before the run. * * @param {HTMLElement} list Sortable list. * @param {number[]} before Order before this change. */ function sortSave(list, before) { var key = list.getAttribute("data-subscrpt-sortable"); var groupId = currentGroupId(); if (!key || !groupId) { return; } var pending = sortPending[key] || { before: before }; sortPending[key] = pending; window.clearTimeout(pending.timer); pending.timer = window.setTimeout(function () { delete sortPending[key]; var ids = sortIds(list); if (ids.join() === pending.before.join()) { return; } var body = {}; body[key] = ids; api("PUT", "/groups/" + groupId + "/order", body) .then(function () { save.notify(i18n.orderSaved); }) .catch(function (err) { sortRestore(list, pending.before); save.notify(err.message || i18n.genericError, "error"); }); }, 300); } /** * Follow the pointer: position the lifted item, swap it past any sibling * whose midpoint it crossed, and keep it inside the list. * * @param {number} clientY Pointer Y in the viewport. */ function sortUpdate(clientY) { var d = sortDrag; var el = d.item; var items = sortVisible(d.list); var first = items[0]; var last = items[items.length - 1]; // Page coordinates, so auto-scroll does not throw the item off the pointer. var dy = clientY + window.scrollY - d.startY; var top = d.startTop + dy; top = Math.max(first.offsetTop, Math.min(top, last.offsetTop + last.offsetHeight - el.offsetHeight)); // Leading edge, not centre: an expanded (tall) product must still be able // to pass a short one inside the clamped range. var bottom = top + el.offsetHeight; var i = items.indexOf(el); var next = items[i + 1]; var prev = items[i - 1]; var movedDown = false; while (next && bottom > next.offsetTop + next.offsetHeight / 2) { sortMove(d.list, el, next.nextSibling, false); items = sortVisible(d.list); next = items[items.indexOf(el) + 1]; movedDown = true; } while (!movedDown && prev && top < prev.offsetTop + prev.offsetHeight / 2) { sortMove(d.list, el, prev, false); items = sortVisible(d.list); prev = items[items.indexOf(el) - 1]; } el.style.transform = "translateY(" + (top - el.offsetTop) + "px) scale(1.01)"; } /** * Scroll the page while the pointer rests near the top/bottom edge. */ function sortAutoScroll() { if (!sortDrag) { return; } var y = sortDrag.clientY; var h = window.innerHeight; var step = 0; if (y < SORT_EDGE_PX) { step = -Math.ceil((SORT_EDGE_PX - y) / 4); } else if (y > h - SORT_EDGE_PX) { step = Math.ceil((y - (h - SORT_EDGE_PX)) / 4); } if (step) { window.scrollBy(0, step); sortUpdate(y); } sortDrag.raf = window.requestAnimationFrame(sortAutoScroll); } function sortOnMove(e) { if (!sortDrag || e.pointerId !== sortDrag.pointerId) { return; } e.preventDefault(); sortDrag.clientY = e.clientY; sortUpdate(e.clientY); } function sortOnEnd(e) { if (!sortDrag || e.pointerId !== sortDrag.pointerId) { return; } var d = sortDrag; var el = d.item; sortDrag = null; window.cancelAnimationFrame(d.raf); window.removeEventListener("pointermove", sortOnMove); window.removeEventListener("pointerup", sortOnEnd); window.removeEventListener("pointercancel", sortOnEnd); document.body.classList.remove("wpsubs-sorting"); // Glide into the slot, then drop the lift. el.classList.remove("wpsubs-sort-item--dragging"); el.classList.add("wpsubs-sort-item--settling"); el.style.transform = ""; window.setTimeout(function () { el.classList.remove("wpsubs-sort-item--settling"); }, 240); if (sortIds(d.list).join() !== d.before.join()) { sortSynced(d.list); sortSave(d.list, d.before); } } document.addEventListener("pointerdown", function (e) { var handle = e.target.closest("[data-subscrpt-sort-handle]"); if (!handle || sortDrag || (e.pointerType === "mouse" && 0 !== e.button)) { return; } var item = handle.closest("[data-subscrpt-sort-item]"); var list = item && item.parentElement; if (!list || !list.hasAttribute("data-subscrpt-sortable")) { return; } e.preventDefault(); handle.focus({ preventScroll: true }); sortDrag = { item: item, list: list, pointerId: e.pointerId, startY: e.clientY + window.scrollY, startTop: item.offsetTop, clientY: e.clientY, before: sortIds(list), }; item.style.transition = ""; item.classList.remove("wpsubs-sort-item--settling"); item.classList.add("wpsubs-sort-item--dragging"); item.style.transform = "scale(1.01)"; document.body.classList.add("wpsubs-sorting"); window.addEventListener("pointermove", sortOnMove, { passive: false }); window.addEventListener("pointerup", sortOnEnd); window.addEventListener("pointercancel", sortOnEnd); sortDrag.raf = window.requestAnimationFrame(sortAutoScroll); }); // Keyboard: ArrowUp / ArrowDown on a focused handle moves one step. document.addEventListener("keydown", function (e) { var handle = e.target.closest ? e.target.closest("[data-subscrpt-sort-handle]") : null; if (!handle || sortDrag || ("ArrowUp" !== e.key && "ArrowDown" !== e.key)) { return; } var item = handle.closest("[data-subscrpt-sort-item]"); var list = item && item.parentElement; if (!list || !list.hasAttribute("data-subscrpt-sortable")) { return; } e.preventDefault(); var items = sortVisible(list); var i = items.indexOf(item); var target = "ArrowUp" === e.key ? items[i - 1] : items[i + 1]; if (!target) { return; } var before = sortIds(list); sortMove(list, item, "ArrowUp" === e.key ? target : target.nextSibling, true); handle.focus({ preventScroll: true }); sortSynced(list); sortSave(list, before); }); /* ------------------------------------------------------------------ * * Products tab: client-side search + pagination over the product list. * ------------------------------------------------------------------ */ /** * Windowed page numbers with ellipses (first, last, current ±1). * * @param {number} current Current page. * @param {number} total Total pages. * @return {Array} */ function pageRange(current, total) { var out = []; for (var i = 1; i <= total; i++) { if (i === 1 || i === total || (i >= current - 1 && i <= current + 1)) { out.push(i); } else if (out[out.length - 1] !== "…") { out.push("…"); } } return out; } function initBrowser(root) { var perPage = parseInt(root.getAttribute("data-per-page"), 10) || 10; var input = root.querySelector("[data-subscrpt-browse-search]"); var items = Array.prototype.slice.call(root.querySelectorAll("[data-subscrpt-browse-item]")); var emptyMsg = root.querySelector("[data-subscrpt-browse-empty]"); var pager = root.querySelector("[data-subscrpt-browse-pager]"); var term = ""; var page = 1; // Remember each item's own display so showing it restores that (e.g. cards // set display:flex inline) instead of clearing it to the block default. items.forEach(function (it) { it._subscrptDisplay = it.style.display || ""; }); function match(item) { if (!term) { return true; } var name = (item.getAttribute("data-name") || "").indexOf(term) !== -1; var pid = String(item.getAttribute("data-pid") || "").indexOf(term) !== -1; return name || pid; } function render() { var visible = items.filter(match); var total = Math.max(1, Math.ceil(visible.length / perPage)); if (page > total) { page = total; } var start = (page - 1) * perPage; var pageItems = visible.slice(start, start + perPage); items.forEach(function (it) { it.style.display = "none"; }); pageItems.forEach(function (it) { it.style.display = it._subscrptDisplay; }); if (emptyMsg) { emptyMsg.style.display = visible.length ? "none" : ""; } renderPager(visible.length, total); } function renderPager(count, total) { if (total <= 1) { pager.innerHTML = ""; return; } var start = count ? (page - 1) * perPage + 1 : 0; var end = Math.min(page * perPage, count); var info = (i18n.showingRange || "Showing %1-%2 of %3") .replace("%1", start) .replace("%2", end) .replace("%3", count); var nav = ""; var chip = function (label, target, disabled, active) { var cls = "wpsubs-pagination__btn"; if (disabled) { cls += " wpsubs-pagination__btn--disabled"; } if (active) { cls += " wpsubs-pagination__btn--active"; } if ("…" === label) { return ''; } return '"; }; nav += chip("‹", page - 1, page <= 1, false); pageRange(page, total).forEach(function (p) { nav += "…" === p ? chip("…") : chip(p, p, false, p === page); }); nav += chip("›", page + 1, page >= total, false); pager.innerHTML = '"; } if (input) { var timer; input.addEventListener("input", function () { window.clearTimeout(timer); timer = window.setTimeout(function () { term = input.value.trim().toLowerCase(); page = 1; render(); }, 200); }); } // Per-page adv-select (fires wpsubs:select). document.addEventListener("wpsubs:select", function (e) { var sel = e.target.closest ? e.target.closest("[data-subscrpt-browse-perpage]") : null; if (!sel || !root.contains(sel)) { return; } perPage = parseInt(e.detail && e.detail.value, 10) || perPage; page = 1; render(); }); // A drag-and-drop reorder moved items in the DOM: page in the new order. root.addEventListener("subscrpt:sorted", function () { items = Array.prototype.slice.call(root.querySelectorAll("[data-subscrpt-browse-item]")); }); pager.addEventListener("click", function (e) { var btn = e.target.closest("[data-subscrpt-page]"); if (!btn) { return; } page = parseInt(btn.getAttribute("data-subscrpt-page"), 10) || 1; render(); }); render(); } function initBrowsers() { document.querySelectorAll("[data-subscrpt-browse]").forEach(initBrowser); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initBrowsers); } else { initBrowsers(); } })();