PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.0.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.0.0
2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 All 61 releases
subscription / assets / js / admin / plans.js

plans.js in Subscriptions for WooCommerce with Stripe Recurring Payments 2.0.0, at assets/js/admin/plans.js

1,412 lines 47.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Subscription Plans - admin interactions (free base).
3 *
4 * Relies on the shared components in assets/js/admin-components/:
5 * - WPSubsModal - data-wpsubs-modal-open / -close, fires wpsubs:modal:open.
6 * - WPSubsTabs - the detail page's Durations / Products tabs.
7 * - WPSubsAccordion - the term list + term-modal sections.
8 *
9 * Everything here is the list/detail chrome plus REST CRUD for plan groups and
10 * durations (terms). All writes go through wpsubscription/v1. The Products
11 * tab is read-only in free, so no product attach/price wiring lives here.
12 */
13 (function () {
14 "use strict";
15
16 var cfg = window.subscrptPlans || {};
17 var i18n = cfg.i18n || {};
18
19 // REST calls, the button-busy lock and every message on this screen come
20 // from the shared component (admin-components/save.js).
21 var save = window.WPSubsSave.bind({ restUrl: cfg.restUrl, nonce: cfg.nonce, i18n: i18n });
22 var api = save.api;
23 var setLoading = save.busy;
24
25 /* ------------------------------------------------------------------ *
26 * Row-actions dropdown (kebab) + client-side list filter.
27 * ------------------------------------------------------------------ */
28
29 document.addEventListener("click", function (e) {
30 var trigger = e.target.closest("[data-subscrpt-dropdown] .wpsubs-row-actions__trigger");
31 if (trigger) {
32 e.preventDefault();
33 var wrap = trigger.closest("[data-subscrpt-dropdown]");
34 var menu = wrap.querySelector(".wpsubs-dropdown");
35 var opening = menu && !menu.classList.contains("wpsubs-dropdown--open");
36 closeDropdowns();
37 if (menu && opening) {
38 menu.hidden = false;
39 menu.classList.add("wpsubs-dropdown--open");
40 wrap.classList.add("wpsubs-row-actions--open");
41 }
42 return;
43 }
44 // A click on a menu item (or anywhere outside the menu) closes the dropdown.
45 if (!e.target.closest(".wpsubs-dropdown") || e.target.closest(".wpsubs-dropdown__item")) {
46 closeDropdowns();
47 }
48 });
49
50 /**
51 * Close every open row-actions dropdown.
52 */
53 function closeDropdowns() {
54 document.querySelectorAll(".wpsubs-row-actions--open").forEach(function (wrap) {
55 wrap.classList.remove("wpsubs-row-actions--open");
56 var menu = wrap.querySelector(".wpsubs-dropdown");
57 if (menu) {
58 menu.classList.remove("wpsubs-dropdown--open");
59 menu.hidden = true;
60 }
61 });
62 }
63
64 // Whole plan-group row opens its detail page — except clicks on a link,
65 // button, or the actions menu, which keep their own behaviour.
66 document.addEventListener("click", function (e) {
67 var row = e.target.closest(".wpsubs-plan-row[data-href]");
68 if (!row || e.target.closest("a, button, input, label, .wpsubs-row-actions, .wpsubs-col--check")) {
69 return;
70 }
71 window.location.href = row.getAttribute("data-href");
72 });
73
74 /* ------------------------------------------------------------------ *
75 * Plan group: create + delete.
76 * ------------------------------------------------------------------ */
77
78 // Plan-group create + the Add / Edit Duration modal now live in the shared
79 // plan-forms.js module (window.WPSubsPlanForms). It dispatches events on
80 // success so this page navigates accordingly.
81
82 // A new plan group was created: open its detail page.
83 document.addEventListener("subscrpt:group-created", function (e) {
84 var group = e.detail && e.detail.group;
85 if (group && group.id) {
86 window.location.href = cfg.listUrl + "&view=detail&plan=" + group.id;
87 }
88 });
89
90 // A duration (term) was created or updated: reload to show it.
91 document.addEventListener("subscrpt:term-saved", function () {
92 window.location.reload();
93 });
94
95 document.addEventListener("click", function (e) {
96 var link = e.target.closest("[data-subscrpt-delete-plan]");
97 if (!link) {
98 return;
99 }
100 e.preventDefault();
101
102 if (!window.confirm(i18n.confirmPlan)) {
103 return;
104 }
105
106 var id = link.getAttribute("data-subscrpt-delete-plan");
107 api("DELETE", "/groups/" + id)
108 .then(function () {
109 var row = link.closest(".wpsubs-plan-row");
110 if (row) {
111 row.parentNode.removeChild(row);
112 } else {
113 window.location.href = cfg.listUrl;
114 }
115 })
116 .catch(function (err) {
117 save.notify(err.message || i18n.genericError, "error");
118 });
119 });
120
121 /* ------------------------------------------------------------------ *
122 * Tab-scoped header actions (Add Duration).
123 * ------------------------------------------------------------------ */
124
125 /**
126 * Show a header action only while the tab it belongs to is selected. The
127 * button sits beside the tab list rather than inside a panel, so it does not
128 * hide with the panel and has to follow the selection itself.
129 */
130 function syncTabActions() {
131 document.querySelectorAll("[data-subscrpt-tab-action]").forEach(function (action) {
132 var tab = document.getElementById(action.getAttribute("data-subscrpt-tab-action"));
133 action.style.display = tab && "true" === tab.getAttribute("aria-selected") ? "" : "none";
134 });
135 }
136
137 document.addEventListener("wpsubs:tab:change", syncTabActions);
138
139 // WPSubsTabs activates the initial tab on DOMContentLoaded; match it either
140 // way so the action is never left showing on the wrong tab.
141 if ("loading" === document.readyState) {
142 document.addEventListener("DOMContentLoaded", syncTabActions);
143 } else {
144 syncTabActions();
145 }
146
147 /* ------------------------------------------------------------------ *
148 * Plan group: inline rename of the detail page title.
149 * ------------------------------------------------------------------ */
150
151 /**
152 * The rename widget's parts, or null when not on the detail page.
153 *
154 * @return {?Object} { wrap, display, pencil, form, input, save }.
155 */
156 function renameParts() {
157 var wrap = document.querySelector("[data-subscrpt-rename]");
158 if (!wrap) {
159 return null;
160 }
161 return {
162 wrap: wrap,
163 display: wrap.querySelector("[data-subscrpt-rename-display]"),
164 pencil: wrap.querySelector("[data-subscrpt-rename-open]"),
165 form: wrap.querySelector("[data-subscrpt-rename-form]"),
166 input: wrap.querySelector("[data-subscrpt-rename-input]"),
167 save: wrap.querySelector("[data-subscrpt-rename-save]"),
168 };
169 }
170
171 /**
172 * Swap the title between its read and edit state.
173 *
174 * @param {boolean} editing Whether to show the input.
175 */
176 function renameToggle(editing) {
177 var parts = renameParts();
178 if (!parts) {
179 return;
180 }
181 parts.display.style.display = editing ? "none" : "";
182 parts.pencil.style.display = editing ? "none" : "";
183 parts.form.style.display = editing ? "inline-flex" : "none";
184
185 if (editing) {
186 parts.input.value = parts.display.textContent.trim();
187 parts.input.focus();
188 parts.input.select();
189 }
190 }
191
192 /**
193 * Mirror the server-side breadcrumb label so the trail matches the new name
194 * without a reload - 30 characters, ellipsis, full text as the title
195 * attribute (see subscrpt_truncate_text()).
196 *
197 * @param {string} name New plan name.
198 */
199 function renameBreadcrumb(name) {
200 var crumb = document.querySelector(".wp-subscription-breadcrumb-current");
201 if (!crumb) {
202 return;
203 }
204 var chars = Array.from(name);
205 var label = chars.length > 30 ? chars.slice(0, 30).join("") + "\u2026" : name;
206
207 crumb.textContent = label;
208 if (label === name) {
209 crumb.removeAttribute("title");
210 } else {
211 crumb.setAttribute("title", name);
212 }
213 }
214
215 /**
216 * Persist the new plan name, then update the heading and breadcrumb in place.
217 */
218 function renameSave() {
219 var parts = renameParts();
220 if (!parts) {
221 return;
222 }
223
224 var host = parts.wrap.closest("[data-plan-id]");
225 var name = parts.input.value.trim();
226
227 if (!name) {
228 save.notify(i18n.nameRequired, "error");
229 parts.input.focus();
230 return;
231 }
232
233 if (!host || name === parts.display.textContent.trim()) {
234 renameToggle(false);
235 return;
236 }
237
238 setLoading(parts.save, true);
239
240 api("PUT", "/groups/" + host.getAttribute("data-plan-id"), { title: name })
241 .then(function () {
242 parts.display.textContent = name;
243 renameBreadcrumb(name);
244 renameToggle(false);
245 save.notify(i18n.saved);
246 })
247 .catch(function (err) {
248 save.notify(err.message || i18n.genericError, "error");
249 })
250 .then(function () {
251 setLoading(parts.save, false);
252 });
253 }
254
255 document.addEventListener("click", function (e) {
256 if (e.target.closest("[data-subscrpt-rename-open]")) {
257 e.preventDefault();
258 renameToggle(true);
259 return;
260 }
261 if (e.target.closest("[data-subscrpt-rename-cancel]")) {
262 e.preventDefault();
263 renameToggle(false);
264 return;
265 }
266 if (e.target.closest("[data-subscrpt-rename-save]")) {
267 e.preventDefault();
268 renameSave();
269 }
270 });
271
272 // Enter commits the rename, Escape abandons it.
273 document.addEventListener("keydown", function (e) {
274 if (!e.target.matches || !e.target.matches("[data-subscrpt-rename-input]")) {
275 return;
276 }
277 if ("Enter" === e.key) {
278 e.preventDefault();
279 renameSave();
280 } else if ("Escape" === e.key) {
281 e.preventDefault();
282 renameToggle(false);
283 }
284 });
285
286 /* ------------------------------------------------------------------ *
287 * Bulk actions on plan groups (select-all + per-row + Apply).
288 * ------------------------------------------------------------------ */
289
290 /**
291 * The per-row checkboxes currently visible (search + pagination hide rows via
292 * display:none). Bulk actions and select-all operate on the visible set only,
293 * so a filtered-out row is never silently deleted.
294 *
295 * @return {HTMLElement[]}
296 */
297 function bulkRowChecks() {
298 return Array.prototype.slice.call(document.querySelectorAll(".wpsubs-row-check")).filter(function (cb) {
299 var tr = cb.closest("tr");
300 return tr && "none" !== tr.style.display;
301 });
302 }
303
304 /** @return {HTMLElement[]} The checked (and visible) per-row checkboxes. */
305 function bulkChecked() {
306 return bulkRowChecks().filter(function (cb) {
307 return cb.checked;
308 });
309 }
310
311 /** Sync the select-all checkbox (checked / indeterminate) with the visible rows. */
312 function bulkSync() {
313 var all = bulkRowChecks();
314 var checked = bulkChecked();
315 var selectAll = document.querySelector("[data-subscrpt-select-all]");
316 if (selectAll) {
317 selectAll.checked = all.length > 0 && checked.length === all.length;
318 selectAll.indeterminate = checked.length > 0 && checked.length < all.length;
319 }
320 }
321
322 /** Reset the bulk-action adv-select back to its placeholder. */
323 function bulkResetSelect() {
324 var root = document.querySelector("[data-subscrpt-bulk-select]");
325 if (!root) {
326 return;
327 }
328 var input = root.querySelector('input[type="hidden"]');
329 var label = root.querySelector(".wpsubs-adv-select__label");
330 if (input) {
331 input.value = root.dataset.defaultValue || "";
332 }
333 if (label) {
334 label.textContent = root.dataset.placeholder || "";
335 }
336 }
337
338 /**
339 * Run a bulk action over the currently-checked plan groups.
340 *
341 * @param {string} action The chosen action value (e.g. "delete").
342 */
343 function bulkRun(action) {
344 if (!action) {
345 return;
346 }
347 var ids = bulkChecked().map(function (cb) {
348 return cb.value;
349 });
350 if (!ids.length) {
351 save.notify(i18n.selectPlans || i18n.genericError, "error");
352 return;
353 }
354
355 if ("delete" === action) {
356 var msg = (i18n.confirmBulkDelete || "").replace("%d", ids.length);
357 if (!window.confirm(msg)) {
358 return;
359 }
360 Promise.all(
361 ids.map(function (id) {
362 return api("DELETE", "/groups/" + id);
363 }),
364 )
365 .then(function () {
366 window.location.reload();
367 })
368 .catch(function (err) {
369 save.notify(err.message || i18n.genericError, "error");
370 });
371 }
372 }
373
374 // Select-all toggles every row checkbox.
375 document.addEventListener("change", function (e) {
376 if (!e.target.closest("[data-subscrpt-select-all]")) {
377 return;
378 }
379 var on = e.target.checked;
380 bulkRowChecks().forEach(function (cb) {
381 cb.checked = on;
382 });
383 bulkSync();
384 });
385
386 // A single row checkbox changed.
387 document.addEventListener("change", function (e) {
388 if (e.target.classList && e.target.classList.contains("wpsubs-row-check")) {
389 bulkSync();
390 }
391 });
392
393 // Picking an option from the bulk-action dropdown runs it immediately, then
394 // resets the dropdown back to its placeholder.
395 document.addEventListener("wpsubs:select", function (e) {
396 if (!e.target.closest || !e.target.closest("[data-subscrpt-bulk-select]")) {
397 return;
398 }
399 var action = e.detail && e.detail.value;
400 bulkResetSelect();
401 bulkRun(action);
402 });
403
404 bulkSync();
405
406 /* ------------------------------------------------------------------ *
407 * Durations (terms): create / edit / delete / toggle.
408 * ------------------------------------------------------------------ */
409
410 // Delete a term.
411 document.addEventListener("click", function (e) {
412 var btn = e.target.closest("[data-subscrpt-delete-term]");
413 if (!btn) {
414 return;
415 }
416 if (!window.confirm(i18n.confirmTerm)) {
417 return;
418 }
419 var id = btn.getAttribute("data-subscrpt-delete-term");
420 api("DELETE", "/terms/" + id)
421 .then(function () {
422 var item = btn.closest("[data-term-id]");
423 if (item) {
424 item.parentNode.removeChild(item);
425 }
426 })
427 .catch(function (err) {
428 save.notify(err.message || i18n.genericError, "error");
429 });
430 });
431
432 /**
433 * Lock a term's toggle while its write is in flight. The switch keeps
434 * showing the state it is moving to, dimmed, so the click reads as accepted
435 * rather than ignored.
436 *
437 * @param {HTMLElement} label The toggle's label.
438 * @param {boolean} busy Busy state.
439 */
440 function setTermBusy(label, busy) {
441 label.setAttribute("aria-busy", busy ? "true" : "false");
442 label.classList.toggle("wpsubs-toggle-busy", busy);
443 }
444
445 /**
446 * Reflect a saved status on the row: the action the toggle now offers, its
447 * tooltip, and the Draft badge beside the name.
448 *
449 * @param {HTMLElement} label The toggle's label.
450 * @param {boolean} active Whether the term is now active.
451 */
452 function applyTermStatus(label, active) {
453 label.setAttribute("data-subscrpt-set-term-status", active ? "draft" : "active");
454 label.setAttribute("title", active ? i18n.setDraft : i18n.setActive);
455
456 var card = label.closest(".wpsubs-table-card");
457 var badge = card && card.querySelector("[data-subscrpt-term-badge]");
458
459 if (active) {
460 if (badge) {
461 badge.parentNode.removeChild(badge);
462 }
463 return;
464 }
465 if (badge || !card) {
466 return;
467 }
468 var row = card.querySelector("[data-subscrpt-term-name-row]");
469 if (!row) {
470 return;
471 }
472 badge = document.createElement("span");
473 badge.className = "wpsubs-badge wpsubs-badge--draft";
474 badge.setAttribute("data-subscrpt-term-badge", "");
475 badge.textContent = i18n.draft;
476 row.appendChild(badge);
477 }
478
479 // Set a term active/draft from its row toggle. The row is updated in place
480 // rather than reloaded: a full reload for a one-field write left the switch
481 // sitting on its old state for the whole round trip, which read as a dead
482 // control.
483 document.addEventListener("click", function (e) {
484 var label = e.target.closest("[data-subscrpt-set-term-status]");
485 if (!label) {
486 return;
487 }
488 // The label owns the checkbox, so let it drive the switch itself.
489 e.preventDefault();
490 if ("true" === label.getAttribute("aria-busy")) {
491 return;
492 }
493
494 var id = label.getAttribute("data-term-id");
495 var status = label.getAttribute("data-subscrpt-set-term-status");
496 var active = "active" === status;
497 var cb = label.querySelector(".wpsubs-toggle");
498
499 setTermBusy(label, true);
500 if (cb) {
501 cb.checked = active;
502 }
503
504 api("PUT", "/terms/" + id, { status: status })
505 .then(function () {
506 applyTermStatus(label, active);
507 save.notify(active ? i18n.termActivated : i18n.termDrafted);
508 })
509 .catch(function (err) {
510 if (cb) {
511 cb.checked = !active;
512 }
513 save.notify(err.message || i18n.genericError, "error");
514 })
515 .then(function () {
516 setTermBusy(label, false);
517 });
518 });
519
520 /* ------------------------------------------------------------------ *
521 * Products tab: refresh the panel in place.
522 * ------------------------------------------------------------------ */
523
524 /**
525 * Re-render the Products panel from the server, keeping the page where it
526 * was. Attaching, detaching and repricing all change server-formatted values
527 * (money, badges, which products exist), so the fragment is fetched rather
528 * than patched by hand — but a full reload for it threw the whole screen
529 * away, which is what made every one of these actions flash.
530 *
531 * Which products were expanded and the scroll position are restored, so the
532 * panel comes back looking like it never moved.
533 *
534 * @param {string|number} groupId Plan group id.
535 * @return {Promise}
536 */
537 function refreshProducts(groupId) {
538 var panel = document.getElementById("subscrpt-panel-products");
539 if (!panel || !groupId) {
540 return Promise.resolve();
541 }
542
543 var scrollY = window.scrollY;
544 var open = [];
545 panel.querySelectorAll("[data-pid]").forEach(function (item) {
546 var header = item.querySelector(".wpsubs-accordion__header");
547 if (header && "true" === header.getAttribute("aria-expanded")) {
548 open.push(item.getAttribute("data-pid"));
549 }
550 });
551
552 return api("GET", "/group-products/" + groupId).then(function (res) {
553 panel.innerHTML = (res && res.html) || "";
554
555 // Components bind on DOMContentLoaded; new markup needs a nudge.
556 if (window.WPSubsAccordion && window.WPSubsAccordion.init) {
557 window.WPSubsAccordion.init(panel);
558 }
559
560 open.forEach(function (pid) {
561 var item = panel.querySelector('[data-pid="' + pid + '"]');
562 var header = item && item.querySelector(".wpsubs-accordion__header");
563 if (header && "true" !== header.getAttribute("aria-expanded")) {
564 header.click();
565 }
566 });
567
568 window.scrollTo(0, scrollY);
569 });
570 }
571
572 /**
573 * The plan group this detail page is showing.
574 *
575 * @return {string} Group id, or "" off the detail page.
576 */
577 function currentGroupId() {
578 var host = document.querySelector("[data-plan-id]");
579 return host ? host.getAttribute("data-plan-id") || "" : "";
580 }
581
582 /* ------------------------------------------------------------------ *
583 * Products tab (Pro): bulk-add products to the plan group.
584 * ------------------------------------------------------------------ */
585
586 /**
587 * Set a variable parent's checkbox from its children's state.
588 *
589 * @param {HTMLElement} parentCb Parent control checkbox.
590 * @param {HTMLElement[]} childBoxes Variation checkboxes.
591 */
592 function syncParent(parentCb, childBoxes) {
593 var all = childBoxes.every(function (c) {
594 return c.checked;
595 });
596 var none = childBoxes.every(function (c) {
597 return !c.checked;
598 });
599 parentCb.checked = all;
600 parentCb.indeterminate = !all && !none;
601 }
602
603 /**
604 * Fetch the group's product relations as a Set of "oid:vid" keys, so the
605 * picker can pre-check what's already attached.
606 *
607 * @param {number|string} groupId Plan group id.
608 * @return {Promise<Set>}
609 */
610 function fetchAttached(groupId) {
611 return api("GET", "/groups/" + groupId)
612 .then(function (group) {
613 var set = new Set();
614 ((group && group.plans) || []).forEach(function (term) {
615 (term.relations || []).forEach(function (rel) {
616 if (1 === parseInt(rel.type, 10)) {
617 set.add(parseInt(rel.oid, 10) + ":" + (parseInt(rel.vid, 10) || 0));
618 }
619 });
620 });
621 return set;
622 })
623 .catch(function () {
624 return new Set();
625 });
626 }
627
628 /**
629 * Build one picker row: checkbox, thumbnail, name, price.
630 *
631 * Attachable rows (simple products, variations) carry data-oid / data-vid /
632 * data-price on the checkbox. A "control" row (variable parent) has none —
633 * it only toggles its children and is skipped when attaching.
634 *
635 * @param {Object} p Product/variation row from the API.
636 * @param {Object} opts { indent, control, parentId }.
637 * @return {{ li: HTMLElement, cb: HTMLElement }}
638 */
639 function productRow(p, opts) {
640 opts = opts || {};
641
642 var li = document.createElement("li");
643 li.style.cssText =
644 "margin:0;display:flex;align-items:center;gap:10px;padding:8px 4px;padding-left:" +
645 (opts.indent ? "36px" : "4px") +
646 ";border-bottom:1px solid rgba(0,0,0,0.06);";
647
648 var label = document.createElement("label");
649 label.style.cssText = "display:flex;align-items:center;gap:10px;flex:1 1 auto;min-width:0;cursor:pointer;";
650
651 // A product already attached to a DIFFERENT plan group can't be selected — a
652 // product belongs to a single group.
653 var inOtherGroup = p.plan_group_id && String(p.plan_group_id) !== String(opts.currentGroup || "");
654
655 var cb = document.createElement("input");
656 cb.type = "checkbox";
657 cb.className = "wpsubs-checkbox";
658 if (inOtherGroup) {
659 cb.disabled = true;
660 }
661 if (!opts.control) {
662 var oid = opts.parentId ? opts.parentId : p.id;
663 var vid = opts.parentId ? p.id : 0;
664 cb.setAttribute("data-oid", oid);
665 cb.setAttribute("data-vid", vid);
666 cb.setAttribute("data-price", p.price || "");
667 // Pre-check rows already attached to this group (never one locked to another).
668 if (!inOtherGroup && opts.attached && opts.attached.has(oid + ":" + vid)) {
669 cb.checked = true;
670 }
671 }
672 label.appendChild(cb);
673
674 var thumb = document.createElement("span");
675 thumb.style.cssText =
676 "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;";
677 if (p.image) {
678 var img = document.createElement("img");
679 img.src = p.image;
680 img.alt = "";
681 img.style.cssText = "width:100%;height:100%;object-fit:cover;display:block;";
682 thumb.appendChild(img);
683 } else {
684 thumb.innerHTML =
685 '<span class="dashicons dashicons-format-image" style="font-size:16px;width:16px;height:16px;color:var(--wpsubs-text-subtle);"></span>';
686 }
687 label.appendChild(thumb);
688
689 var name = document.createElement("span");
690 name.textContent = p.name;
691 name.style.cssText = "font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;";
692 label.appendChild(name);
693
694 if (inOtherGroup) {
695 li.style.opacity = "0.55";
696 label.style.cursor = "not-allowed";
697 li.title = (i18n.usedInPlan || "This product is being used in '%s'").replace("%s", p.plan_group_name || "");
698 var note = document.createElement("span");
699 note.textContent = i18n.inOtherPlan || "Added to another plan";
700 note.style.cssText = "flex:0 0 auto;font-size:11px;font-style:italic;color:var(--wpsubs-text-subtle);";
701 label.appendChild(note);
702 }
703
704 var price = document.createElement("span");
705 price.style.cssText = "color:var(--wpsubs-text-muted);font-size:13px;flex:0 0 auto;";
706 price.textContent = p.price_html || "";
707
708 li.appendChild(label);
709 li.appendChild(price);
710
711 return { li: li, cb: cb };
712 }
713
714 /**
715 * Populate the Add Products picker with a product search.
716 *
717 * @param {HTMLElement} modal The add-product modal.
718 * @param {string} search Search term.
719 */
720 function loadProducts(modal, search) {
721 var list = modal.querySelector("[data-subscrpt-product-list]");
722 if (!list) {
723 return;
724 }
725 var attached = modal._attached || new Set();
726 var currentGroup = modal.getAttribute("data-group-id") || "";
727 list.innerHTML =
728 '<li style="padding:10px 4px;color:var(--wpsubs-text-subtle);font-size:13px;">' +
729 (i18n.loading || "Loading…") +
730 "</li>";
731
732 api("GET", "/products?search=" + encodeURIComponent(search || ""))
733 .then(function (products) {
734 if (!products.length) {
735 list.innerHTML =
736 '<li style="padding:10px 4px;color:var(--wpsubs-text-subtle);font-size:13px;">' +
737 (i18n.noProducts || "No products found.") +
738 "</li>";
739 return;
740 }
741 list.innerHTML = "";
742 products.forEach(function (p) {
743 var variations = p.variations || [];
744
745 if (variations.length) {
746 // Variable product: parent row is a select-all control (no own
747 // relation), variations are the attachable rows offset beneath it.
748 var parent = productRow(p, { indent: false, control: true, currentGroup: currentGroup });
749 list.appendChild(parent.li);
750
751 var childBoxes = [];
752 variations.forEach(function (v) {
753 var child = productRow(v, {
754 indent: true,
755 parentId: p.id,
756 attached: attached,
757 currentGroup: currentGroup,
758 });
759 childBoxes.push(child.cb);
760 list.appendChild(child.li);
761 });
762
763 // Reflect the pre-checked children in the parent control.
764 syncParent(parent.cb, childBoxes);
765
766 // Parent toggles every child; children keep the parent in sync.
767 parent.cb.addEventListener("change", function () {
768 childBoxes.forEach(function (cb) {
769 cb.checked = parent.cb.checked;
770 });
771 parent.cb.indeterminate = false;
772 });
773 childBoxes.forEach(function (cb) {
774 cb.addEventListener("change", function () {
775 syncParent(parent.cb, childBoxes);
776 });
777 });
778 } else {
779 list.appendChild(productRow(p, { indent: false, attached: attached, currentGroup: currentGroup }).li);
780 }
781 });
782
783 // No divider under the last row.
784 if (list.lastElementChild) {
785 list.lastElementChild.style.borderBottom = "none";
786 }
787 syncPickerCount(modal);
788 })
789 .catch(function () {
790 list.innerHTML =
791 '<li style="padding:10px 4px;color:var(--wpsubs-text-subtle);font-size:13px;">' +
792 (i18n.genericError || "") +
793 "</li>";
794 });
795 }
796
797 /**
798 * Update the picker's running tally. Ticking is a two-way control — it
799 * attaches and detaches — so the count is what says how the plan will look
800 * after saving, rather than how many were just clicked.
801 *
802 * @param {HTMLElement} modal The add-product modal.
803 */
804 function syncPickerCount(modal) {
805 var out = modal && modal.querySelector("[data-subscrpt-picker-count]");
806 if (!out) {
807 return;
808 }
809 var boxes = modal.querySelectorAll("[data-subscrpt-product-list] input[data-oid]");
810 var n = 0;
811 Array.prototype.forEach.call(boxes, function (box) {
812 if (box.checked) {
813 n += 1;
814 }
815 });
816 out.textContent = n ? (i18n.picked || "%d on this plan").replace("%d", n) : i18n.pickedNone || "";
817 }
818
819 // Any tick in the picker updates the tally.
820 document.addEventListener("change", function (e) {
821 var box = e.target.closest("[data-subscrpt-product-list] input[type=checkbox]");
822 var modal = box && box.closest("[data-subscrpt-add-product]");
823 if (modal) {
824 syncPickerCount(modal);
825 }
826 });
827
828 // Load the picker when the modal opens (pre-checking attached products).
829 document.addEventListener("wpsubs:modal:open", function (e) {
830 var modal = e.target;
831 if (modal && modal.id === "subscrpt-add-product") {
832 fetchAttached(modal.getAttribute("data-group-id")).then(function (set) {
833 modal._attached = set;
834 loadProducts(modal, "");
835 });
836 }
837 });
838
839 // Debounced product search.
840 var addProductTimer;
841 document.addEventListener("input", function (e) {
842 var input = e.target.closest("[data-subscrpt-product-search]");
843 if (!input) {
844 return;
845 }
846 var modal = input.closest("[data-subscrpt-add-product]");
847 window.clearTimeout(addProductTimer);
848 addProductTimer = window.setTimeout(function () {
849 loadProducts(modal, input.value.trim());
850 }, 300);
851 });
852
853 // Attach the checked products to every duration in the group.
854 document.addEventListener("click", function (e) {
855 var btn = e.target.closest("[data-subscrpt-add-product-submit]");
856 if (!btn) {
857 return;
858 }
859 var modal = btn.closest("[data-subscrpt-add-product]");
860 var groupId = modal.getAttribute("data-group-id");
861 // Only attachable rows carry data-oid (variable parents are skipped).
862 var boxes = modal.querySelectorAll("[data-subscrpt-product-list] input[data-oid]");
863
864 // Sync the group to the picker: attach checked rows, detach any previously
865 // attached row that is now unchecked.
866 var checkedKeys = new Set();
867 var checked = [];
868 Array.prototype.forEach.call(boxes, function (box) {
869 if (box.checked) {
870 checked.push(box);
871 checkedKeys.add(box.getAttribute("data-oid") + ":" + (box.getAttribute("data-vid") || "0"));
872 }
873 });
874 var attached = modal._attached || new Set();
875 var toRemove = new Set();
876 attached.forEach(function (key) {
877 if (!checkedKeys.has(key)) {
878 toRemove.add(key);
879 }
880 });
881
882 if (!checked.length && !toRemove.size) {
883 return;
884 }
885
886 setLoading(btn, true);
887 api("GET", "/groups/" + groupId)
888 .then(function (group) {
889 var terms = (group && group.plans) || [];
890 var isInstallments = group && ("installments" === group.type_key || 3 === parseInt(group.type, 10));
891 var calls = [];
892
893 // Attach checked rows to every term.
894 checked.forEach(function (box) {
895 var price = box.getAttribute("data-price") || "";
896 var data = isInstallments
897 ? { price_per_installment: price, down_payment: "" }
898 : { regular_price: price, sale_price: "" };
899 terms.forEach(function (term) {
900 calls.push(
901 api("POST", "/relations", {
902 plan_id: term.id,
903 oid: parseInt(box.getAttribute("data-oid"), 10),
904 vid: parseInt(box.getAttribute("data-vid"), 10) || 0,
905 type: 1,
906 status: "active",
907 data: data,
908 }),
909 );
910 });
911 });
912
913 // Detach rows that were unchecked.
914 if (toRemove.size) {
915 terms.forEach(function (term) {
916 (term.relations || []).forEach(function (rel) {
917 if (1 !== parseInt(rel.type, 10)) {
918 return;
919 }
920 var key = parseInt(rel.oid, 10) + ":" + (parseInt(rel.vid, 10) || 0);
921 if (toRemove.has(key)) {
922 calls.push(api("DELETE", "/relations/" + rel.id));
923 }
924 });
925 });
926 }
927
928 return Promise.all(calls);
929 })
930 .then(function () {
931 if (window.WPSubsModal) {
932 window.WPSubsModal.close(modal);
933 }
934 return refreshProducts(groupId);
935 })
936 .then(function () {
937 setLoading(btn, false);
938 save.notify(i18n.productsUpdated);
939 })
940 .catch(function (err) {
941 setLoading(btn, false);
942 save.notify(err.message || i18n.genericError, "error");
943 });
944 });
945
946 // Remove a product from the group: delete every relation for that product
947 // (all durations, all variations).
948 document.addEventListener("click", function (e) {
949 var btn = e.target.closest("[data-subscrpt-remove-product]");
950 if (!btn) {
951 return;
952 }
953 var oid = parseInt(btn.getAttribute("data-subscrpt-remove-product"), 10);
954 var wrap = document.querySelector("[data-plan-id]");
955 var groupId = wrap && wrap.getAttribute("data-plan-id");
956 if (!groupId || !window.confirm(i18n.confirmRemoveProduct || "Remove this product from the plan?")) {
957 return;
958 }
959
960 setLoading(btn, true);
961 api("GET", "/groups/" + groupId)
962 .then(function (group) {
963 var ids = [];
964 ((group && group.plans) || []).forEach(function (term) {
965 (term.relations || []).forEach(function (rel) {
966 if (1 === parseInt(rel.type, 10) && parseInt(rel.oid, 10) === oid) {
967 ids.push(rel.id);
968 }
969 });
970 });
971 return Promise.all(
972 ids.map(function (id) {
973 return api("DELETE", "/relations/" + id);
974 }),
975 );
976 })
977 .then(function () {
978 return refreshProducts(groupId);
979 })
980 .then(function () {
981 setLoading(btn, false);
982 save.notify(i18n.productRemoved);
983 })
984 .catch(function (err) {
985 setLoading(btn, false);
986 save.notify(err.message || i18n.genericError, "error");
987 });
988 });
989
990 // Remove a single variation from the group: delete every relation for that
991 // (product, variation) pair across all durations.
992 document.addEventListener("click", function (e) {
993 var btn = e.target.closest("[data-subscrpt-remove-variation]");
994 if (!btn) {
995 return;
996 }
997 var oid = parseInt(btn.getAttribute("data-oid"), 10);
998 var vid = parseInt(btn.getAttribute("data-vid"), 10);
999 var wrap = document.querySelector("[data-plan-id]");
1000 var groupId = wrap && wrap.getAttribute("data-plan-id");
1001 if (!groupId || !window.confirm(i18n.confirmRemoveVariation || "Remove this variation from the plan?")) {
1002 return;
1003 }
1004
1005 setLoading(btn, true);
1006 api("GET", "/groups/" + groupId)
1007 .then(function (group) {
1008 var ids = [];
1009 ((group && group.plans) || []).forEach(function (term) {
1010 (term.relations || []).forEach(function (rel) {
1011 if (1 === parseInt(rel.type, 10) && parseInt(rel.oid, 10) === oid && parseInt(rel.vid, 10) === vid) {
1012 ids.push(rel.id);
1013 }
1014 });
1015 });
1016 return Promise.all(
1017 ids.map(function (id) {
1018 return api("DELETE", "/relations/" + id);
1019 }),
1020 );
1021 })
1022 .then(function () {
1023 return refreshProducts(groupId);
1024 })
1025 .then(function () {
1026 setLoading(btn, false);
1027 save.notify(i18n.productRemoved);
1028 })
1029 .catch(function (err) {
1030 setLoading(btn, false);
1031 save.notify(err.message || i18n.genericError, "error");
1032 });
1033 });
1034
1035 /* ------------------------------------------------------------------ *
1036 * Products tab (Pro): inline edit of a price card's rows.
1037 * ------------------------------------------------------------------ */
1038
1039 /**
1040 * Toggle a price card between read and edit mode.
1041 *
1042 * @param {HTMLElement} card The [data-subscrpt-price-card] element.
1043 * @param {boolean} editing Desired mode.
1044 */
1045 function priceCardMode(card, editing) {
1046 card.querySelectorAll(".subscrpt-pe-view").forEach(function (el) {
1047 el.style.display = editing ? "none" : "";
1048 });
1049 card.querySelectorAll(".subscrpt-pe-edit").forEach(function (el) {
1050 el.style.display = editing ? "" : "none";
1051 });
1052 var toggle = function (sel, show) {
1053 var btn = card.querySelector(sel);
1054 if (btn) {
1055 btn.style.display = show ? "" : "none";
1056 }
1057 };
1058 toggle("[data-subscrpt-edit-prices]", !editing);
1059 toggle("[data-subscrpt-cancel-prices]", editing);
1060 toggle("[data-subscrpt-save-prices]", editing);
1061 }
1062
1063 // Enter edit mode (snapshot current values for cancel).
1064 document.addEventListener("click", function (e) {
1065 var btn = e.target.closest("[data-subscrpt-edit-prices]");
1066 if (!btn) {
1067 return;
1068 }
1069 var card = btn.closest("[data-subscrpt-price-card]");
1070 card.querySelectorAll("[data-field]").forEach(function (field) {
1071 field.dataset.orig = "checkbox" === field.type ? (field.checked ? "1" : "") : field.value;
1072 });
1073 priceCardMode(card, true);
1074 });
1075
1076 // Cancel: restore snapshot, back to read mode.
1077 document.addEventListener("click", function (e) {
1078 var btn = e.target.closest("[data-subscrpt-cancel-prices]");
1079 if (!btn) {
1080 return;
1081 }
1082 var card = btn.closest("[data-subscrpt-price-card]");
1083 card.querySelectorAll("[data-field]").forEach(function (field) {
1084 if ("checkbox" === field.type) {
1085 field.checked = "1" === field.dataset.orig;
1086 } else {
1087 field.value = field.dataset.orig || "";
1088 }
1089 });
1090 priceCardMode(card, false);
1091 });
1092
1093 // Save: PUT each row's regular / offer price + enabled state to its relation.
1094 document.addEventListener("click", function (e) {
1095 var btn = e.target.closest("[data-subscrpt-save-prices]");
1096 if (!btn) {
1097 return;
1098 }
1099 var card = btn.closest("[data-subscrpt-price-card]");
1100 var rows = card.querySelectorAll("[data-subscrpt-relation]");
1101 // The card carries the product id, and (for a variable product) the
1102 // variation id — a variation row seeded from the product-level price has
1103 // no relation of its own yet, so Save creates one for that variation.
1104 var cardPid = card.getAttribute("data-product-id");
1105 var cardVid = parseInt(card.getAttribute("data-variation-id"), 10) || 0;
1106
1107 setLoading(btn, true);
1108 var calls = Array.prototype.map.call(rows, function (row) {
1109 var id = parseInt(row.getAttribute("data-subscrpt-relation"), 10) || 0;
1110 var reg = row.querySelector('[data-field="regular_price"]');
1111 var sale = row.querySelector('[data-field="sale_price"]');
1112 var enabled = row.querySelector('[data-field="enabled"]');
1113 var data = {
1114 regular_price: reg ? reg.value : "",
1115 sale_price: sale ? sale.value : "",
1116 discount_value: 0,
1117 };
1118 if (id) {
1119 return api("PUT", "/relations/" + id, {
1120 exclude: enabled ? !enabled.checked : false,
1121 data: data,
1122 });
1123 }
1124 // No relation yet (seeded variation price) → create one for this variation.
1125 return api("POST", "/relations", {
1126 plan_id: parseInt(row.getAttribute("data-plan-id"), 10) || 0,
1127 oid: parseInt(cardPid, 10) || 0,
1128 vid: cardVid,
1129 type: 1,
1130 status: "active",
1131 exclude: enabled ? !enabled.checked : false,
1132 data: data,
1133 });
1134 });
1135
1136 // One-time purchase row (product-specific native price) shares this Save.
1137 // Only persisted when its controls are rendered (Pro): the one-time price
1138 // stays restricted, so leave it untouched when those inputs are absent.
1139 var otRow = card.querySelector("[data-subscrpt-onetime-row]");
1140 var pid = card.getAttribute("data-product-id");
1141 var enable = otRow ? otRow.querySelector("[data-subscrpt-onetime-enable]") : null;
1142 if (otRow && pid && enable) {
1143 var vid = card.getAttribute("data-variation-id");
1144 var otPrice = otRow.querySelector('[data-ot-field="price"]');
1145 var otOffer = otRow.querySelector('[data-ot-field="offer"]');
1146 var otVals = {
1147 enabled: enable.checked,
1148 price: otPrice ? otPrice.value : "",
1149 offer: otOffer ? otOffer.value : "",
1150 };
1151 var otPayload = otVals;
1152 if (vid) {
1153 otPayload = { variations: {} };
1154 otPayload.variations[vid] = otVals;
1155 }
1156 calls.push(api("PUT", "/product-onetime/" + pid, otPayload));
1157 }
1158
1159 Promise.all(calls)
1160 .then(function () {
1161 return refreshProducts(currentGroupId());
1162 })
1163 .then(function () {
1164 setLoading(btn, false);
1165 save.notify(i18n.pricesSaved);
1166 })
1167 .catch(function (err) {
1168 setLoading(btn, false);
1169 save.notify(err.message || i18n.genericError, "error");
1170 });
1171 });
1172
1173 /* ------------------------------------------------------------------ *
1174 * Products tab (Pro): simple product one-time purchase card.
1175 * Variable products handle one-time per variation via the card Save above;
1176 * simple products use this standalone card + its own toggle/Save.
1177 * ------------------------------------------------------------------ */
1178
1179 /**
1180 * PUT a simple product's one-time purchase: the switch as it stands, plus prices.
1181 *
1182 * The endpoint writes the prices it is given and clears any it is not, so
1183 * both are always sent.
1184 *
1185 * @param {HTMLElement} card The one-time card.
1186 * @param {{price: string, offer: string}} prices Prices to store.
1187 * @return {Promise} The request.
1188 */
1189 function putOneTime(card, prices) {
1190 var enable = card.querySelector("[data-subscrpt-onetime-enable]");
1191 return api("PUT", "/product-onetime/" + card.getAttribute("data-product-id"), {
1192 enabled: enable ? enable.checked : false,
1193 price: prices.price,
1194 offer: prices.offer,
1195 });
1196 }
1197
1198 // Save the simple card's prices (Save sits in the price row).
1199 document.addEventListener("click", function (e) {
1200 var btn = e.target.closest("[data-subscrpt-onetime-save]");
1201 if (!btn) {
1202 return;
1203 }
1204 var card = btn.closest("[data-subscrpt-onetime-card]");
1205 var price = card.querySelector('[data-ot-field="price"]');
1206 var offer = card.querySelector('[data-ot-field="offer"]');
1207
1208 setLoading(btn, true);
1209 putOneTime(card, { price: price ? price.value : "", offer: offer ? offer.value : "" })
1210 .then(function () {
1211 return refreshProducts(currentGroupId());
1212 })
1213 .then(function () {
1214 setLoading(btn, false);
1215 save.notify(i18n.pricesSaved);
1216 })
1217 .catch(function (err) {
1218 setLoading(btn, false);
1219 save.notify(err.message || i18n.genericError, "error");
1220 });
1221 });
1222
1223 // The switch saves itself. Save lives in the price row, which hides while the
1224 // switch is off, so "off" could not be saved any other way. It sends the
1225 // prices as last saved — each input's default value — because the endpoint
1226 // clears a price it is not given, and an unsaved price edit is left for Save.
1227 // No product refresh: the card already shows what was stored.
1228 document.addEventListener("change", function (e) {
1229 var toggle = e.target.closest("[data-subscrpt-onetime-card] [data-subscrpt-onetime-enable]");
1230 if (!toggle || toggle.subscrptReverting) {
1231 return;
1232 }
1233 var card = toggle.closest("[data-subscrpt-onetime-card]");
1234 var price = card.querySelector('[data-ot-field="price"]');
1235 var offer = card.querySelector('[data-ot-field="offer"]');
1236
1237 toggle.disabled = true;
1238 putOneTime(card, { price: price ? price.defaultValue : "", offer: offer ? offer.defaultValue : "" })
1239 .then(function () {
1240 toggle.disabled = false;
1241 save.notify(toggle.checked ? i18n.oneTimeOn : i18n.oneTimeOff);
1242 })
1243 .catch(function (err) {
1244 toggle.disabled = false;
1245 // Put the switch back so it shows what is stored; the re-dispatched
1246 // change re-syncs the price row without saving again.
1247 toggle.subscrptReverting = true;
1248 toggle.checked = !toggle.checked;
1249 toggle.dispatchEvent(new Event("change", { bubbles: true }));
1250 toggle.subscrptReverting = false;
1251 save.notify(err.message || i18n.genericError, "error");
1252 });
1253 });
1254
1255 /* ------------------------------------------------------------------ *
1256 * Products tab: client-side search + pagination over the product list.
1257 * ------------------------------------------------------------------ */
1258
1259 /**
1260 * Windowed page numbers with ellipses (first, last, current ±1).
1261 *
1262 * @param {number} current Current page.
1263 * @param {number} total Total pages.
1264 * @return {Array<number|string>}
1265 */
1266 function pageRange(current, total) {
1267 var out = [];
1268 for (var i = 1; i <= total; i++) {
1269 if (i === 1 || i === total || (i >= current - 1 && i <= current + 1)) {
1270 out.push(i);
1271 } else if (out[out.length - 1] !== "") {
1272 out.push("");
1273 }
1274 }
1275 return out;
1276 }
1277
1278 function initBrowser(root) {
1279 var perPage = parseInt(root.getAttribute("data-per-page"), 10) || 10;
1280 var input = root.querySelector("[data-subscrpt-browse-search]");
1281 var items = Array.prototype.slice.call(root.querySelectorAll("[data-subscrpt-browse-item]"));
1282 var emptyMsg = root.querySelector("[data-subscrpt-browse-empty]");
1283 var pager = root.querySelector("[data-subscrpt-browse-pager]");
1284 var term = "";
1285 var page = 1;
1286
1287 // Remember each item's own display so showing it restores that (e.g. cards
1288 // set display:flex inline) instead of clearing it to the block default.
1289 items.forEach(function (it) {
1290 it._subscrptDisplay = it.style.display || "";
1291 });
1292
1293 function match(item) {
1294 if (!term) {
1295 return true;
1296 }
1297 var name = (item.getAttribute("data-name") || "").indexOf(term) !== -1;
1298 var pid = String(item.getAttribute("data-pid") || "").indexOf(term) !== -1;
1299 return name || pid;
1300 }
1301
1302 function render() {
1303 var visible = items.filter(match);
1304 var total = Math.max(1, Math.ceil(visible.length / perPage));
1305 if (page > total) {
1306 page = total;
1307 }
1308 var start = (page - 1) * perPage;
1309 var pageItems = visible.slice(start, start + perPage);
1310
1311 items.forEach(function (it) {
1312 it.style.display = "none";
1313 });
1314 pageItems.forEach(function (it) {
1315 it.style.display = it._subscrptDisplay;
1316 });
1317
1318 if (emptyMsg) {
1319 emptyMsg.style.display = visible.length ? "none" : "";
1320 }
1321 renderPager(visible.length, total);
1322 }
1323
1324 function renderPager(count, total) {
1325 if (total <= 1) {
1326 pager.innerHTML = "";
1327 return;
1328 }
1329 var start = count ? (page - 1) * perPage + 1 : 0;
1330 var end = Math.min(page * perPage, count);
1331 var info = (i18n.showingRange || "Showing %1-%2 of %3")
1332 .replace("%1", start)
1333 .replace("%2", end)
1334 .replace("%3", count);
1335
1336 var nav = "";
1337 var chip = function (label, target, disabled, active) {
1338 var cls = "wpsubs-pagination__btn";
1339 if (disabled) {
1340 cls += " wpsubs-pagination__btn--disabled";
1341 }
1342 if (active) {
1343 cls += " wpsubs-pagination__btn--active";
1344 }
1345 if ("" === label) {
1346 return '<span class="wpsubs-pagination__btn wpsubs-pagination__btn--ellipsis" aria-hidden="true">…</span>';
1347 }
1348 return '<button type="button" class="' + cls + '" data-subscrpt-page="' + target + '">' + label + "</button>";
1349 };
1350
1351 nav += chip("", page - 1, page <= 1, false);
1352 pageRange(page, total).forEach(function (p) {
1353 nav += "" === p ? chip("") : chip(p, p, false, p === page);
1354 });
1355 nav += chip("", page + 1, page >= total, false);
1356
1357 pager.innerHTML =
1358 '<div class="wpsubs-pagination" role="navigation">' +
1359 '<span class="wpsubs-pagination__info">' +
1360 info +
1361 "</span>" +
1362 '<span class="wpsubs-pagination__nav">' +
1363 nav +
1364 "</span></div>";
1365 }
1366
1367 if (input) {
1368 var timer;
1369 input.addEventListener("input", function () {
1370 window.clearTimeout(timer);
1371 timer = window.setTimeout(function () {
1372 term = input.value.trim().toLowerCase();
1373 page = 1;
1374 render();
1375 }, 200);
1376 });
1377 }
1378
1379 // Per-page adv-select (fires wpsubs:select).
1380 document.addEventListener("wpsubs:select", function (e) {
1381 var sel = e.target.closest ? e.target.closest("[data-subscrpt-browse-perpage]") : null;
1382 if (!sel || !root.contains(sel)) {
1383 return;
1384 }
1385 perPage = parseInt(e.detail && e.detail.value, 10) || perPage;
1386 page = 1;
1387 render();
1388 });
1389
1390 pager.addEventListener("click", function (e) {
1391 var btn = e.target.closest("[data-subscrpt-page]");
1392 if (!btn) {
1393 return;
1394 }
1395 page = parseInt(btn.getAttribute("data-subscrpt-page"), 10) || 1;
1396 render();
1397 });
1398
1399 render();
1400 }
1401
1402 function initBrowsers() {
1403 document.querySelectorAll("[data-subscrpt-browse]").forEach(initBrowser);
1404 }
1405
1406 if (document.readyState === "loading") {
1407 document.addEventListener("DOMContentLoaded", initBrowsers);
1408 } else {
1409 initBrowsers();
1410 }
1411 })();
1412