PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.3.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.3.0
2.4.0 2.3.0 2.2.0 2.1.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 All 65 releases
subscription / assets / js / admin / plans.js

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

1,958 lines 65.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: bulk-set one price across products x durations.
584 * ------------------------------------------------------------------ */
585
586 /**
587 * Reflect a variable product's variations in its parent control checkbox:
588 * on when all are ticked, indeterminate when some are.
589 *
590 * @param {HTMLElement} group The [data-subscrpt-bulk-group] wrapper.
591 */
592 function syncBulkParent(group) {
593 var parent = group && group.querySelector("[data-subscrpt-bulk-parent]");
594 if (!parent) {
595 return;
596 }
597 var kids = group.querySelectorAll("[data-subscrpt-bulk-product]");
598 var checked = 0;
599 kids.forEach(function (cb) {
600 if (cb.checked) {
601 checked += 1;
602 }
603 });
604 parent.checked = kids.length > 0 && checked === kids.length;
605 parent.indeterminate = checked > 0 && checked < kids.length;
606 }
607
608 /**
609 * Update the "N selected" tally beside each column header.
610 *
611 * @param {HTMLElement} modal The bulk-price modal.
612 */
613 function syncBulkCounts(modal) {
614 [
615 ["term", "[data-subscrpt-bulk-term]"],
616 ["product", "[data-subscrpt-bulk-product]"],
617 ].forEach(function (pair) {
618 var out = modal.querySelector('[data-subscrpt-bulk-count="' + pair[0] + '"]');
619 if (!out) {
620 return;
621 }
622 var n = modal.querySelectorAll(pair[1] + ":checked").length;
623 out.textContent = n ? (i18n.bulkSelected || "%d selected").replace("%d", n) : "";
624 });
625 }
626
627 // Any change inside the modal refreshes the per-column tallies.
628 document.addEventListener("change", function (e) {
629 var modal = e.target.closest("[data-subscrpt-bulk-price]");
630 if (modal) {
631 syncBulkCounts(modal);
632 }
633 });
634
635 // A column's "All" checkbox ticks/unticks every row in that column.
636 document.addEventListener("change", function (e) {
637 var all = e.target.closest("[data-subscrpt-bulk-toggle-all]");
638 var modal = all && all.closest("[data-subscrpt-bulk-price]");
639 if (!modal) {
640 return;
641 }
642 var col = all.getAttribute("data-subscrpt-bulk-toggle-all");
643 var sel = "product" === col ? "[data-subscrpt-bulk-product]" : "[data-subscrpt-bulk-term]";
644 modal.querySelectorAll(sel).forEach(function (cb) {
645 if (!cb.disabled) {
646 cb.checked = all.checked;
647 }
648 });
649 if ("product" === col) {
650 modal.querySelectorAll("[data-subscrpt-bulk-parent]").forEach(function (parent) {
651 parent.checked = all.checked;
652 parent.indeterminate = false;
653 });
654 }
655 });
656
657 // A variable product's parent control ticks/unticks all its variations.
658 document.addEventListener("change", function (e) {
659 var parent = e.target.closest("[data-subscrpt-bulk-parent]");
660 var group = parent && parent.closest("[data-subscrpt-bulk-group]");
661 if (!group || !parent.closest("[data-subscrpt-bulk-price]")) {
662 return;
663 }
664 group.querySelectorAll("[data-subscrpt-bulk-product]").forEach(function (cb) {
665 if (!cb.disabled) {
666 cb.checked = parent.checked;
667 }
668 });
669 parent.indeterminate = false;
670 });
671
672 // A variation tick keeps its parent control in sync.
673 document.addEventListener("change", function (e) {
674 var child = e.target.closest("[data-subscrpt-bulk-product]");
675 var group = child && child.closest("[data-subscrpt-bulk-group]");
676 if (!group || !child.closest("[data-subscrpt-bulk-price]")) {
677 return;
678 }
679 syncBulkParent(group);
680 });
681
682 // Apply the entered price to every selected product on every selected duration.
683 document.addEventListener("click", function (e) {
684 var btn = e.target.closest("[data-subscrpt-bulk-apply]");
685 var modal = btn && btn.closest("[data-subscrpt-bulk-price]");
686 if (!modal) {
687 return;
688 }
689
690 var regInput = modal.querySelector("[data-subscrpt-bulk-regular]");
691 var saleInput = modal.querySelector("[data-subscrpt-bulk-sale]");
692 var regular = regInput ? regInput.value.trim() : "";
693 if ("" === regular) {
694 save.notify(i18n.bulkNoPrice || i18n.genericError, "error");
695 return;
696 }
697
698 var products = Array.prototype.map.call(
699 modal.querySelectorAll("[data-subscrpt-bulk-product]:checked"),
700 function (cb) {
701 return { oid: cb.getAttribute("data-oid"), vid: cb.getAttribute("data-vid") || 0 };
702 },
703 );
704 if (!products.length) {
705 save.notify(i18n.bulkNoProducts || i18n.genericError, "error");
706 return;
707 }
708
709 var planIds = Array.prototype.map.call(modal.querySelectorAll("[data-subscrpt-bulk-term]:checked"), function (cb) {
710 return cb.value;
711 });
712 if (!planIds.length) {
713 save.notify(i18n.bulkNoTerms || i18n.genericError, "error");
714 return;
715 }
716
717 setLoading(btn, true);
718 api("POST", "/bulk-price", {
719 plan_ids: planIds,
720 products: products,
721 regular_price: regular,
722 sale_price: saleInput ? saleInput.value.trim() : "",
723 })
724 .then(function () {
725 // Close before the panel refresh replaces (and orphans) the modal —
726 // otherwise its body-scroll lock is never released.
727 if (window.WPSubsModal && window.WPSubsModal.close) {
728 window.WPSubsModal.close("subscrpt-bulk-price");
729 }
730 return refreshProducts(currentGroupId());
731 })
732 .then(function () {
733 setLoading(btn, false);
734 save.notify(i18n.bulkApplied || i18n.pricesSaved);
735 })
736 .catch(function (err) {
737 setLoading(btn, false);
738 save.notify(err.message || i18n.genericError, "error");
739 });
740 });
741
742 /* ------------------------------------------------------------------ *
743 * Products tab (Pro): bulk-add products to the plan group.
744 * ------------------------------------------------------------------ */
745
746 /**
747 * Set a variable parent's checkbox from its children's state.
748 *
749 * @param {HTMLElement} parentCb Parent control checkbox.
750 * @param {HTMLElement[]} childBoxes Variation checkboxes.
751 */
752 function syncParent(parentCb, childBoxes) {
753 var all = childBoxes.every(function (c) {
754 return c.checked;
755 });
756 var none = childBoxes.every(function (c) {
757 return !c.checked;
758 });
759 parentCb.checked = all;
760 parentCb.indeterminate = !all && !none;
761 }
762
763 /**
764 * Fetch the group's product relations as a Set of "oid:vid" keys, so the
765 * picker can pre-check what's already attached.
766 *
767 * @param {number|string} groupId Plan group id.
768 * @return {Promise<Set>}
769 */
770 function fetchAttached(groupId) {
771 return api("GET", "/groups/" + groupId)
772 .then(function (group) {
773 var set = new Set();
774 ((group && group.plans) || []).forEach(function (term) {
775 (term.relations || []).forEach(function (rel) {
776 if (1 === parseInt(rel.type, 10)) {
777 set.add(parseInt(rel.oid, 10) + ":" + (parseInt(rel.vid, 10) || 0));
778 }
779 });
780 });
781 return set;
782 })
783 .catch(function () {
784 return new Set();
785 });
786 }
787
788 /**
789 * Build one picker row: checkbox, thumbnail, name, price.
790 *
791 * Attachable rows (simple products, variations) carry data-oid / data-vid /
792 * data-price on the checkbox. A "control" row (variable parent) has none —
793 * it only toggles its children and is skipped when attaching.
794 *
795 * @param {Object} p Product/variation row from the API.
796 * @param {Object} opts { indent, control, parentId }.
797 * @return {{ li: HTMLElement, cb: HTMLElement }}
798 */
799 function productRow(p, opts) {
800 opts = opts || {};
801
802 var li = document.createElement("li");
803 li.style.cssText =
804 "margin:0;display:flex;align-items:center;gap:10px;padding:8px 4px;padding-left:" +
805 (opts.indent ? "36px" : "4px") +
806 ";border-bottom:1px solid rgba(0,0,0,0.06);";
807
808 var label = document.createElement("label");
809 label.style.cssText = "display:flex;align-items:center;gap:10px;flex:1 1 auto;min-width:0;cursor:pointer;";
810
811 // A product already attached to a DIFFERENT plan group can't be selected — a
812 // product belongs to a single group.
813 var inOtherGroup = p.plan_group_id && String(p.plan_group_id) !== String(opts.currentGroup || "");
814
815 var cb = document.createElement("input");
816 cb.type = "checkbox";
817 cb.className = "wpsubs-checkbox";
818 if (inOtherGroup) {
819 cb.disabled = true;
820 }
821 if (!opts.control) {
822 var oid = opts.parentId ? opts.parentId : p.id;
823 var vid = opts.parentId ? p.id : 0;
824 cb.setAttribute("data-oid", oid);
825 cb.setAttribute("data-vid", vid);
826 cb.setAttribute("data-price", p.price || "");
827 // Pre-check rows already attached to this group (never one locked to another).
828 if (!inOtherGroup && opts.attached && opts.attached.has(oid + ":" + vid)) {
829 cb.checked = true;
830 }
831 }
832 label.appendChild(cb);
833
834 var thumb = document.createElement("span");
835 thumb.style.cssText =
836 "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;";
837 if (p.image) {
838 var img = document.createElement("img");
839 img.src = p.image;
840 img.alt = "";
841 img.style.cssText = "width:100%;height:100%;object-fit:cover;display:block;";
842 thumb.appendChild(img);
843 } else {
844 thumb.innerHTML =
845 '<span class="dashicons dashicons-format-image" style="font-size:16px;width:16px;height:16px;color:var(--wpsubs-text-subtle);"></span>';
846 }
847 label.appendChild(thumb);
848
849 var name = document.createElement("span");
850 name.textContent = p.name;
851 name.style.cssText = "font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;";
852 label.appendChild(name);
853
854 if (inOtherGroup) {
855 li.style.opacity = "0.55";
856 label.style.cursor = "not-allowed";
857 li.title = (i18n.usedInPlan || "This product is being used in '%s'").replace("%s", p.plan_group_name || "");
858 var note = document.createElement("span");
859 note.textContent = i18n.inOtherPlan || "Added to another plan";
860 note.style.cssText = "flex:0 0 auto;font-size:11px;font-style:italic;color:var(--wpsubs-text-subtle);";
861 label.appendChild(note);
862 }
863
864 var price = document.createElement("span");
865 price.style.cssText = "color:var(--wpsubs-text-muted);font-size:13px;flex:0 0 auto;";
866 price.textContent = p.price_html || "";
867
868 li.appendChild(label);
869 li.appendChild(price);
870
871 return { li: li, cb: cb };
872 }
873
874 /**
875 * Populate the Add Products picker with a product search.
876 *
877 * @param {HTMLElement} modal The add-product modal.
878 * @param {string} search Search term.
879 */
880 function loadProducts(modal, search) {
881 var list = modal.querySelector("[data-subscrpt-product-list]");
882 if (!list) {
883 return;
884 }
885 var attached = modal._attached || new Set();
886 var currentGroup = modal.getAttribute("data-group-id") || "";
887 list.innerHTML =
888 '<li style="padding:10px 4px;color:var(--wpsubs-text-subtle);font-size:13px;">' +
889 (i18n.loading || "Loading…") +
890 "</li>";
891
892 api("GET", "/products?search=" + encodeURIComponent(search || ""))
893 .then(function (products) {
894 if (!products.length) {
895 list.innerHTML =
896 '<li style="padding:10px 4px;color:var(--wpsubs-text-subtle);font-size:13px;">' +
897 (i18n.noProducts || "No products found.") +
898 "</li>";
899 return;
900 }
901 list.innerHTML = "";
902 products.forEach(function (p) {
903 var variations = p.variations || [];
904
905 if (variations.length) {
906 // Variable product: parent row is a select-all control (no own
907 // relation), variations are the attachable rows offset beneath it.
908 var parent = productRow(p, { indent: false, control: true, currentGroup: currentGroup });
909 list.appendChild(parent.li);
910
911 var childBoxes = [];
912 variations.forEach(function (v) {
913 var child = productRow(v, {
914 indent: true,
915 parentId: p.id,
916 attached: attached,
917 currentGroup: currentGroup,
918 });
919 childBoxes.push(child.cb);
920 list.appendChild(child.li);
921 });
922
923 // Reflect the pre-checked children in the parent control.
924 syncParent(parent.cb, childBoxes);
925
926 // Parent toggles every child; children keep the parent in sync.
927 parent.cb.addEventListener("change", function () {
928 childBoxes.forEach(function (cb) {
929 cb.checked = parent.cb.checked;
930 });
931 parent.cb.indeterminate = false;
932 });
933 childBoxes.forEach(function (cb) {
934 cb.addEventListener("change", function () {
935 syncParent(parent.cb, childBoxes);
936 });
937 });
938 } else {
939 list.appendChild(productRow(p, { indent: false, attached: attached, currentGroup: currentGroup }).li);
940 }
941 });
942
943 // No divider under the last row.
944 if (list.lastElementChild) {
945 list.lastElementChild.style.borderBottom = "none";
946 }
947 syncPickerCount(modal);
948 syncSelectAll(modal);
949 })
950 .catch(function () {
951 list.innerHTML =
952 '<li style="padding:10px 4px;color:var(--wpsubs-text-subtle);font-size:13px;">' +
953 (i18n.genericError || "") +
954 "</li>";
955 });
956 }
957
958 /**
959 * Update the picker's running tally. Ticking is a two-way control — it
960 * attaches and detaches — so the count is what says how the plan will look
961 * after saving, rather than how many were just clicked.
962 *
963 * @param {HTMLElement} modal The add-product modal.
964 */
965 function syncPickerCount(modal) {
966 var out = modal && modal.querySelector("[data-subscrpt-picker-count]");
967 if (!out) {
968 return;
969 }
970 var boxes = modal.querySelectorAll("[data-subscrpt-product-list] input[data-oid]");
971 var n = 0;
972 Array.prototype.forEach.call(boxes, function (box) {
973 if (box.checked) {
974 n += 1;
975 }
976 });
977 out.textContent = n ? (i18n.picked || "%d on this plan").replace("%d", n) : i18n.pickedNone || "";
978 }
979
980 /**
981 * The picker's attachable, non-locked checkboxes — what select-all acts on.
982 * Rows locked to another plan are disabled and left out.
983 *
984 * @param {HTMLElement} modal The add-product modal.
985 * @return {HTMLElement[]} Enabled row checkboxes.
986 */
987 function selectableBoxes(modal) {
988 var boxes = modal.querySelectorAll("[data-subscrpt-product-list] input[data-oid]");
989 return Array.prototype.filter.call(boxes, function (box) {
990 return !box.disabled;
991 });
992 }
993
994 /**
995 * Reflect the rows in the select-all checkbox: on when all are ticked,
996 * indeterminate when some are, off when none (disabled when nothing to pick).
997 *
998 * @param {HTMLElement} modal The add-product modal.
999 */
1000 function syncSelectAll(modal) {
1001 var toggle = modal && modal.querySelector("[data-subscrpt-picker-all]");
1002 if (!toggle) {
1003 return;
1004 }
1005 var boxes = selectableBoxes(modal);
1006 var checked = boxes.filter(function (box) {
1007 return box.checked;
1008 }).length;
1009 toggle.disabled = 0 === boxes.length;
1010 toggle.checked = boxes.length > 0 && checked === boxes.length;
1011 toggle.indeterminate = checked > 0 && checked < boxes.length;
1012 }
1013
1014 // Any tick in the picker updates the tally and the select-all state.
1015 document.addEventListener("change", function (e) {
1016 var box = e.target.closest("[data-subscrpt-product-list] input[type=checkbox]");
1017 var modal = box && box.closest("[data-subscrpt-add-product]");
1018 if (modal) {
1019 syncPickerCount(modal);
1020 syncSelectAll(modal);
1021 }
1022 });
1023
1024 // Select-all ticks/unticks every enabled row. Select-all makes the picker
1025 // uniform, so parent controls and children all take the same state directly —
1026 // no synthetic change events (which would re-enter syncSelectAll mid-loop).
1027 document.addEventListener("change", function (e) {
1028 var toggle = e.target.closest("[data-subscrpt-picker-all]");
1029 var modal = toggle && toggle.closest("[data-subscrpt-add-product]");
1030 if (!modal) {
1031 return;
1032 }
1033 var checked = toggle.checked;
1034 var boxes = modal.querySelectorAll("[data-subscrpt-product-list] input[type=checkbox]");
1035 Array.prototype.forEach.call(boxes, function (box) {
1036 if (box.disabled) {
1037 return;
1038 }
1039 box.checked = checked;
1040 box.indeterminate = false;
1041 });
1042 toggle.checked = checked;
1043 toggle.indeterminate = false;
1044 syncPickerCount(modal);
1045 });
1046
1047 // Load the picker when the modal opens (pre-checking attached products).
1048 document.addEventListener("wpsubs:modal:open", function (e) {
1049 var modal = e.target;
1050 if (modal && modal.id === "subscrpt-add-product") {
1051 fetchAttached(modal.getAttribute("data-group-id")).then(function (set) {
1052 modal._attached = set;
1053 loadProducts(modal, "");
1054 });
1055 }
1056 });
1057
1058 // Debounced product search.
1059 var addProductTimer;
1060 document.addEventListener("input", function (e) {
1061 var input = e.target.closest("[data-subscrpt-product-search]");
1062 if (!input) {
1063 return;
1064 }
1065 var modal = input.closest("[data-subscrpt-add-product]");
1066 window.clearTimeout(addProductTimer);
1067 addProductTimer = window.setTimeout(function () {
1068 loadProducts(modal, input.value.trim());
1069 }, 300);
1070 });
1071
1072 // Attach the checked products to every duration in the group.
1073 document.addEventListener("click", function (e) {
1074 var btn = e.target.closest("[data-subscrpt-add-product-submit]");
1075 if (!btn) {
1076 return;
1077 }
1078 var modal = btn.closest("[data-subscrpt-add-product]");
1079 var groupId = modal.getAttribute("data-group-id");
1080 // Only attachable rows carry data-oid (variable parents are skipped).
1081 var boxes = modal.querySelectorAll("[data-subscrpt-product-list] input[data-oid]");
1082
1083 // Sync the group to the picker: attach checked rows, detach any previously
1084 // attached row that is now unchecked.
1085 var checkedKeys = new Set();
1086 var checked = [];
1087 Array.prototype.forEach.call(boxes, function (box) {
1088 if (box.checked) {
1089 checked.push(box);
1090 checkedKeys.add(box.getAttribute("data-oid") + ":" + (box.getAttribute("data-vid") || "0"));
1091 }
1092 });
1093 var attached = modal._attached || new Set();
1094 var toRemove = new Set();
1095 attached.forEach(function (key) {
1096 if (!checkedKeys.has(key)) {
1097 toRemove.add(key);
1098 }
1099 });
1100
1101 if (!checked.length && !toRemove.size) {
1102 return;
1103 }
1104
1105 setLoading(btn, true);
1106 api("GET", "/groups/" + groupId)
1107 .then(function (group) {
1108 var terms = (group && group.plans) || [];
1109 var isInstallments = group && ("installments" === group.type_key || 3 === parseInt(group.type, 10));
1110 var calls = [];
1111
1112 // Attach checked rows to every term.
1113 checked.forEach(function (box) {
1114 var price = box.getAttribute("data-price") || "";
1115 var data = isInstallments
1116 ? { price_per_installment: price, down_payment: "" }
1117 : { regular_price: price, sale_price: "" };
1118 terms.forEach(function (term) {
1119 calls.push(
1120 api("POST", "/relations", {
1121 plan_id: term.id,
1122 oid: parseInt(box.getAttribute("data-oid"), 10),
1123 vid: parseInt(box.getAttribute("data-vid"), 10) || 0,
1124 type: 1,
1125 status: "active",
1126 data: data,
1127 }),
1128 );
1129 });
1130 });
1131
1132 // Detach rows that were unchecked.
1133 if (toRemove.size) {
1134 terms.forEach(function (term) {
1135 (term.relations || []).forEach(function (rel) {
1136 if (1 !== parseInt(rel.type, 10)) {
1137 return;
1138 }
1139 var key = parseInt(rel.oid, 10) + ":" + (parseInt(rel.vid, 10) || 0);
1140 if (toRemove.has(key)) {
1141 calls.push(api("DELETE", "/relations/" + rel.id));
1142 }
1143 });
1144 });
1145 }
1146
1147 return Promise.all(calls);
1148 })
1149 .then(function () {
1150 if (window.WPSubsModal) {
1151 window.WPSubsModal.close(modal);
1152 }
1153 return refreshProducts(groupId);
1154 })
1155 .then(function () {
1156 setLoading(btn, false);
1157 save.notify(i18n.productsUpdated);
1158 })
1159 .catch(function (err) {
1160 setLoading(btn, false);
1161 save.notify(err.message || i18n.genericError, "error");
1162 });
1163 });
1164
1165 // Remove a product from the group: delete every relation for that product
1166 // (all durations, all variations).
1167 document.addEventListener("click", function (e) {
1168 var btn = e.target.closest("[data-subscrpt-remove-product]");
1169 if (!btn) {
1170 return;
1171 }
1172 var oid = parseInt(btn.getAttribute("data-subscrpt-remove-product"), 10);
1173 var wrap = document.querySelector("[data-plan-id]");
1174 var groupId = wrap && wrap.getAttribute("data-plan-id");
1175 if (!groupId || !window.confirm(i18n.confirmRemoveProduct || "Remove this product from the plan?")) {
1176 return;
1177 }
1178
1179 setLoading(btn, true);
1180 api("GET", "/groups/" + groupId)
1181 .then(function (group) {
1182 var ids = [];
1183 ((group && group.plans) || []).forEach(function (term) {
1184 (term.relations || []).forEach(function (rel) {
1185 if (1 === parseInt(rel.type, 10) && parseInt(rel.oid, 10) === oid) {
1186 ids.push(rel.id);
1187 }
1188 });
1189 });
1190 return Promise.all(
1191 ids.map(function (id) {
1192 return api("DELETE", "/relations/" + id);
1193 }),
1194 );
1195 })
1196 .then(function () {
1197 return refreshProducts(groupId);
1198 })
1199 .then(function () {
1200 setLoading(btn, false);
1201 save.notify(i18n.productRemoved);
1202 })
1203 .catch(function (err) {
1204 setLoading(btn, false);
1205 save.notify(err.message || i18n.genericError, "error");
1206 });
1207 });
1208
1209 // Remove a single variation from the group: delete every relation for that
1210 // (product, variation) pair across all durations.
1211 document.addEventListener("click", function (e) {
1212 var btn = e.target.closest("[data-subscrpt-remove-variation]");
1213 if (!btn) {
1214 return;
1215 }
1216 var oid = parseInt(btn.getAttribute("data-oid"), 10);
1217 var vid = parseInt(btn.getAttribute("data-vid"), 10);
1218 var wrap = document.querySelector("[data-plan-id]");
1219 var groupId = wrap && wrap.getAttribute("data-plan-id");
1220 if (!groupId || !window.confirm(i18n.confirmRemoveVariation || "Remove this variation from the plan?")) {
1221 return;
1222 }
1223
1224 setLoading(btn, true);
1225 api("GET", "/groups/" + groupId)
1226 .then(function (group) {
1227 var ids = [];
1228 ((group && group.plans) || []).forEach(function (term) {
1229 (term.relations || []).forEach(function (rel) {
1230 if (1 === parseInt(rel.type, 10) && parseInt(rel.oid, 10) === oid && parseInt(rel.vid, 10) === vid) {
1231 ids.push(rel.id);
1232 }
1233 });
1234 });
1235 return Promise.all(
1236 ids.map(function (id) {
1237 return api("DELETE", "/relations/" + id);
1238 }),
1239 );
1240 })
1241 .then(function () {
1242 return refreshProducts(groupId);
1243 })
1244 .then(function () {
1245 setLoading(btn, false);
1246 save.notify(i18n.productRemoved);
1247 })
1248 .catch(function (err) {
1249 setLoading(btn, false);
1250 save.notify(err.message || i18n.genericError, "error");
1251 });
1252 });
1253
1254 /* ------------------------------------------------------------------ *
1255 * Products tab (Pro): inline edit of a price card's rows.
1256 * ------------------------------------------------------------------ */
1257
1258 /**
1259 * Toggle a price card between read and edit mode.
1260 *
1261 * @param {HTMLElement} card The [data-subscrpt-price-card] element.
1262 * @param {boolean} editing Desired mode.
1263 */
1264 function priceCardMode(card, editing) {
1265 card.querySelectorAll(".subscrpt-pe-view").forEach(function (el) {
1266 el.style.display = editing ? "none" : "";
1267 });
1268 card.querySelectorAll(".subscrpt-pe-edit").forEach(function (el) {
1269 el.style.display = editing ? "" : "none";
1270 });
1271 var toggle = function (sel, show) {
1272 var btn = card.querySelector(sel);
1273 if (btn) {
1274 btn.style.display = show ? "" : "none";
1275 }
1276 };
1277 toggle("[data-subscrpt-edit-prices]", !editing);
1278 toggle("[data-subscrpt-cancel-prices]", editing);
1279 toggle("[data-subscrpt-save-prices]", editing);
1280 }
1281
1282 // Enter edit mode (snapshot current values for cancel).
1283 document.addEventListener("click", function (e) {
1284 var btn = e.target.closest("[data-subscrpt-edit-prices]");
1285 if (!btn) {
1286 return;
1287 }
1288 var card = btn.closest("[data-subscrpt-price-card]");
1289 card.querySelectorAll("[data-field]").forEach(function (field) {
1290 field.dataset.orig = "checkbox" === field.type ? (field.checked ? "1" : "") : field.value;
1291 });
1292 priceCardMode(card, true);
1293 });
1294
1295 // Cancel: restore snapshot, back to read mode.
1296 document.addEventListener("click", function (e) {
1297 var btn = e.target.closest("[data-subscrpt-cancel-prices]");
1298 if (!btn) {
1299 return;
1300 }
1301 var card = btn.closest("[data-subscrpt-price-card]");
1302 card.querySelectorAll("[data-field]").forEach(function (field) {
1303 if ("checkbox" === field.type) {
1304 field.checked = "1" === field.dataset.orig;
1305 } else {
1306 field.value = field.dataset.orig || "";
1307 }
1308 });
1309 priceCardMode(card, false);
1310 });
1311
1312 // Save: PUT each row's regular / offer price + enabled state to its relation.
1313 document.addEventListener("click", function (e) {
1314 var btn = e.target.closest("[data-subscrpt-save-prices]");
1315 if (!btn) {
1316 return;
1317 }
1318 var card = btn.closest("[data-subscrpt-price-card]");
1319 var rows = card.querySelectorAll("[data-subscrpt-relation]");
1320 // The card carries the product id, and (for a variable product) the
1321 // variation id — a variation row seeded from the product-level price has
1322 // no relation of its own yet, so Save creates one for that variation.
1323 var cardPid = card.getAttribute("data-product-id");
1324 var cardVid = parseInt(card.getAttribute("data-variation-id"), 10) || 0;
1325
1326 setLoading(btn, true);
1327 var calls = Array.prototype.map.call(rows, function (row) {
1328 var id = parseInt(row.getAttribute("data-subscrpt-relation"), 10) || 0;
1329 var reg = row.querySelector('[data-field="regular_price"]');
1330 var sale = row.querySelector('[data-field="sale_price"]');
1331 var enabled = row.querySelector('[data-field="enabled"]');
1332 var data = {
1333 regular_price: reg ? reg.value : "",
1334 sale_price: sale ? sale.value : "",
1335 discount_value: 0,
1336 };
1337 if (id) {
1338 return api("PUT", "/relations/" + id, {
1339 exclude: enabled ? !enabled.checked : false,
1340 data: data,
1341 });
1342 }
1343 // No relation yet (seeded variation price) → create one for this variation.
1344 return api("POST", "/relations", {
1345 plan_id: parseInt(row.getAttribute("data-plan-id"), 10) || 0,
1346 oid: parseInt(cardPid, 10) || 0,
1347 vid: cardVid,
1348 type: 1,
1349 status: "active",
1350 exclude: enabled ? !enabled.checked : false,
1351 data: data,
1352 });
1353 });
1354
1355 // One-time purchase row (product-specific native price) shares this Save.
1356 // Only persisted when its controls are rendered (Pro): the one-time price
1357 // stays restricted, so leave it untouched when those inputs are absent.
1358 var otRow = card.querySelector("[data-subscrpt-onetime-row]");
1359 var pid = card.getAttribute("data-product-id");
1360 var enable = otRow ? otRow.querySelector("[data-subscrpt-onetime-enable]") : null;
1361 if (otRow && pid && enable) {
1362 var vid = card.getAttribute("data-variation-id");
1363 var otPrice = otRow.querySelector('[data-ot-field="price"]');
1364 var otOffer = otRow.querySelector('[data-ot-field="offer"]');
1365 var otVals = {
1366 enabled: enable.checked,
1367 price: otPrice ? otPrice.value : "",
1368 offer: otOffer ? otOffer.value : "",
1369 };
1370 var otPayload = otVals;
1371 if (vid) {
1372 otPayload = { variations: {} };
1373 otPayload.variations[vid] = otVals;
1374 }
1375 calls.push(api("PUT", "/product-onetime/" + pid, otPayload));
1376 }
1377
1378 Promise.all(calls)
1379 .then(function () {
1380 return refreshProducts(currentGroupId());
1381 })
1382 .then(function () {
1383 setLoading(btn, false);
1384 save.notify(i18n.pricesSaved);
1385 })
1386 .catch(function (err) {
1387 setLoading(btn, false);
1388 save.notify(err.message || i18n.genericError, "error");
1389 });
1390 });
1391
1392 /* ------------------------------------------------------------------ *
1393 * Products tab (Pro): simple product one-time purchase card.
1394 * Variable products handle one-time per variation via the card Save above;
1395 * simple products use this standalone card + its own toggle/Save.
1396 * ------------------------------------------------------------------ */
1397
1398 /**
1399 * PUT a simple product's one-time purchase: the switch as it stands, plus prices.
1400 *
1401 * The endpoint writes the prices it is given and clears any it is not, so
1402 * both are always sent.
1403 *
1404 * @param {HTMLElement} card The one-time card.
1405 * @param {{price: string, offer: string}} prices Prices to store.
1406 * @return {Promise} The request.
1407 */
1408 function putOneTime(card, prices) {
1409 var enable = card.querySelector("[data-subscrpt-onetime-enable]");
1410 return api("PUT", "/product-onetime/" + card.getAttribute("data-product-id"), {
1411 enabled: enable ? enable.checked : false,
1412 price: prices.price,
1413 offer: prices.offer,
1414 });
1415 }
1416
1417 // Save the simple card's prices (Save sits in the price row).
1418 document.addEventListener("click", function (e) {
1419 var btn = e.target.closest("[data-subscrpt-onetime-save]");
1420 if (!btn) {
1421 return;
1422 }
1423 var card = btn.closest("[data-subscrpt-onetime-card]");
1424 var price = card.querySelector('[data-ot-field="price"]');
1425 var offer = card.querySelector('[data-ot-field="offer"]');
1426
1427 setLoading(btn, true);
1428 putOneTime(card, { price: price ? price.value : "", offer: offer ? offer.value : "" })
1429 .then(function () {
1430 return refreshProducts(currentGroupId());
1431 })
1432 .then(function () {
1433 setLoading(btn, false);
1434 save.notify(i18n.pricesSaved);
1435 })
1436 .catch(function (err) {
1437 setLoading(btn, false);
1438 save.notify(err.message || i18n.genericError, "error");
1439 });
1440 });
1441
1442 // The switch saves itself. Save lives in the price row, which hides while the
1443 // switch is off, so "off" could not be saved any other way. It sends the
1444 // prices as last saved — each input's default value — because the endpoint
1445 // clears a price it is not given, and an unsaved price edit is left for Save.
1446 // No product refresh: the card already shows what was stored.
1447 document.addEventListener("change", function (e) {
1448 var toggle = e.target.closest("[data-subscrpt-onetime-card] [data-subscrpt-onetime-enable]");
1449 if (!toggle || toggle.subscrptReverting) {
1450 return;
1451 }
1452 var card = toggle.closest("[data-subscrpt-onetime-card]");
1453 var price = card.querySelector('[data-ot-field="price"]');
1454 var offer = card.querySelector('[data-ot-field="offer"]');
1455
1456 toggle.disabled = true;
1457 putOneTime(card, { price: price ? price.defaultValue : "", offer: offer ? offer.defaultValue : "" })
1458 .then(function () {
1459 toggle.disabled = false;
1460 save.notify(toggle.checked ? i18n.oneTimeOn : i18n.oneTimeOff);
1461 })
1462 .catch(function (err) {
1463 toggle.disabled = false;
1464 // Put the switch back so it shows what is stored; the re-dispatched
1465 // change re-syncs the price row without saving again.
1466 toggle.subscrptReverting = true;
1467 toggle.checked = !toggle.checked;
1468 toggle.dispatchEvent(new Event("change", { bubbles: true }));
1469 toggle.subscrptReverting = false;
1470 save.notify(err.message || i18n.genericError, "error");
1471 });
1472 });
1473
1474 // "Allow User Cancellation" saves itself too; on failure the switch goes back
1475 // to what is stored.
1476 document.addEventListener("change", function (e) {
1477 var toggle = e.target.closest("[data-subscrpt-user-cancel-card] [data-subscrpt-user-cancel-enable]");
1478 if (!toggle) {
1479 return;
1480 }
1481 var card = toggle.closest("[data-subscrpt-user-cancel-card]");
1482
1483 toggle.disabled = true;
1484 api("PUT", "/product-user-cancel/" + card.getAttribute("data-product-id"), { enabled: toggle.checked })
1485 .then(function () {
1486 toggle.disabled = false;
1487 save.notify(toggle.checked ? i18n.userCancelOn : i18n.userCancelOff);
1488 })
1489 .catch(function (err) {
1490 toggle.disabled = false;
1491 toggle.checked = !toggle.checked;
1492 save.notify(err.message || i18n.genericError, "error");
1493 });
1494 });
1495
1496 /* ------------------------------------------------------------------ *
1497 * Drag-and-drop reorder: durations + products (vertical only).
1498 *
1499 * A drag starts on [data-subscrpt-sort-handle] only, moves its
1500 * [data-subscrpt-sort-item] within the [data-subscrpt-sortable] list, and
1501 * saves the list's full id order (hidden/paged-out items included) to
1502 * /groups/{id}/order. Siblings slide aside with a FLIP transition. Arrow
1503 * keys on a focused handle move it one step. Everything is delegated,
1504 * because the Products panel is re-rendered in place.
1505 * ------------------------------------------------------------------ */
1506
1507 var SORT_ANIM_MS = 200;
1508 var SORT_EDGE_PX = 60;
1509 var sortDrag = null;
1510 var sortPending = {};
1511
1512 /**
1513 * Items of a list the merchant can currently see (search/pager aware).
1514 *
1515 * @param {HTMLElement} list Sortable list.
1516 * @return {HTMLElement[]}
1517 */
1518 function sortVisible(list) {
1519 return Array.prototype.filter.call(list.children, function (el) {
1520 return el.hasAttribute("data-subscrpt-sort-item") && "none" !== el.style.display;
1521 });
1522 }
1523
1524 /**
1525 * Every item id of a list, in DOM order.
1526 *
1527 * @param {HTMLElement} list Sortable list.
1528 * @return {number[]}
1529 */
1530 function sortIds(list) {
1531 return Array.prototype.map.call(list.querySelectorAll(":scope > [data-subscrpt-sort-item]"), function (el) {
1532 return parseInt(el.getAttribute("data-subscrpt-sort-item"), 10);
1533 });
1534 }
1535
1536 /**
1537 * Move `el` in the DOM (before `ref`, or to the end) and slide every other
1538 * visible item from where it was to where it now is.
1539 *
1540 * @param {HTMLElement} list Sortable list.
1541 * @param {HTMLElement} el Item to move.
1542 * @param {HTMLElement|null} ref Insert before this item.
1543 * @param {boolean} self Animate `el` too (keyboard moves).
1544 */
1545 function sortMove(list, el, ref, self) {
1546 var others = sortVisible(list).filter(function (it) {
1547 return self || it !== el;
1548 });
1549 var before = others.map(function (it) {
1550 return it.offsetTop;
1551 });
1552
1553 list.insertBefore(el, ref);
1554
1555 others.forEach(function (it, i) {
1556 var delta = before[i] - it.offsetTop;
1557 if (!delta) {
1558 return;
1559 }
1560 it.style.transition = "none";
1561 it.style.transform = "translateY(" + delta + "px)";
1562 void it.offsetHeight; // Commit the start frame.
1563 it.style.transition = "transform " + SORT_ANIM_MS + "ms cubic-bezier(0.2, 0, 0, 1)";
1564 it.style.transform = "";
1565 window.clearTimeout(it._subscrptSortTimer);
1566 it._subscrptSortTimer = window.setTimeout(function () {
1567 it.style.transition = "";
1568 }, SORT_ANIM_MS);
1569 });
1570 }
1571
1572 /**
1573 * Put a list back into a given id order (a failed save).
1574 *
1575 * @param {HTMLElement} list Sortable list.
1576 * @param {number[]} ids Order to restore.
1577 */
1578 function sortRestore(list, ids) {
1579 ids.forEach(function (id) {
1580 var el = list.querySelector(':scope > [data-subscrpt-sort-item="' + id + '"]');
1581 if (el) {
1582 list.appendChild(el);
1583 }
1584 });
1585 sortSynced(list);
1586 }
1587
1588 /**
1589 * Tell the list's search/pager the DOM order changed.
1590 *
1591 * @param {HTMLElement} list Sortable list.
1592 */
1593 function sortSynced(list) {
1594 var root = list.closest("[data-subscrpt-browse]");
1595 if (root) {
1596 root.dispatchEvent(new CustomEvent("subscrpt:sorted"));
1597 }
1598 }
1599
1600 /**
1601 * Save a list's order. Debounced so a run of arrow-key moves is one write;
1602 * on failure the list goes back to the order it had before the run.
1603 *
1604 * @param {HTMLElement} list Sortable list.
1605 * @param {number[]} before Order before this change.
1606 */
1607 function sortSave(list, before) {
1608 var key = list.getAttribute("data-subscrpt-sortable");
1609 var groupId = currentGroupId();
1610 if (!key || !groupId) {
1611 return;
1612 }
1613
1614 var pending = sortPending[key] || { before: before };
1615 sortPending[key] = pending;
1616 window.clearTimeout(pending.timer);
1617 pending.timer = window.setTimeout(function () {
1618 delete sortPending[key];
1619 var ids = sortIds(list);
1620 if (ids.join() === pending.before.join()) {
1621 return;
1622 }
1623 var body = {};
1624 body[key] = ids;
1625 api("PUT", "/groups/" + groupId + "/order", body)
1626 .then(function () {
1627 save.notify(i18n.orderSaved);
1628 })
1629 .catch(function (err) {
1630 sortRestore(list, pending.before);
1631 save.notify(err.message || i18n.genericError, "error");
1632 });
1633 }, 300);
1634 }
1635
1636 /**
1637 * Follow the pointer: position the lifted item, swap it past any sibling
1638 * whose midpoint it crossed, and keep it inside the list.
1639 *
1640 * @param {number} clientY Pointer Y in the viewport.
1641 */
1642 function sortUpdate(clientY) {
1643 var d = sortDrag;
1644 var el = d.item;
1645 var items = sortVisible(d.list);
1646 var first = items[0];
1647 var last = items[items.length - 1];
1648
1649 // Page coordinates, so auto-scroll does not throw the item off the pointer.
1650 var dy = clientY + window.scrollY - d.startY;
1651 var top = d.startTop + dy;
1652 top = Math.max(first.offsetTop, Math.min(top, last.offsetTop + last.offsetHeight - el.offsetHeight));
1653 // Leading edge, not centre: an expanded (tall) product must still be able
1654 // to pass a short one inside the clamped range.
1655 var bottom = top + el.offsetHeight;
1656
1657 var i = items.indexOf(el);
1658 var next = items[i + 1];
1659 var prev = items[i - 1];
1660 var movedDown = false;
1661 while (next && bottom > next.offsetTop + next.offsetHeight / 2) {
1662 sortMove(d.list, el, next.nextSibling, false);
1663 items = sortVisible(d.list);
1664 next = items[items.indexOf(el) + 1];
1665 movedDown = true;
1666 }
1667 while (!movedDown && prev && top < prev.offsetTop + prev.offsetHeight / 2) {
1668 sortMove(d.list, el, prev, false);
1669 items = sortVisible(d.list);
1670 prev = items[items.indexOf(el) - 1];
1671 }
1672
1673 el.style.transform = "translateY(" + (top - el.offsetTop) + "px) scale(1.01)";
1674 }
1675
1676 /**
1677 * Scroll the page while the pointer rests near the top/bottom edge.
1678 */
1679 function sortAutoScroll() {
1680 if (!sortDrag) {
1681 return;
1682 }
1683 var y = sortDrag.clientY;
1684 var h = window.innerHeight;
1685 var step = 0;
1686 if (y < SORT_EDGE_PX) {
1687 step = -Math.ceil((SORT_EDGE_PX - y) / 4);
1688 } else if (y > h - SORT_EDGE_PX) {
1689 step = Math.ceil((y - (h - SORT_EDGE_PX)) / 4);
1690 }
1691 if (step) {
1692 window.scrollBy(0, step);
1693 sortUpdate(y);
1694 }
1695 sortDrag.raf = window.requestAnimationFrame(sortAutoScroll);
1696 }
1697
1698 function sortOnMove(e) {
1699 if (!sortDrag || e.pointerId !== sortDrag.pointerId) {
1700 return;
1701 }
1702 e.preventDefault();
1703 sortDrag.clientY = e.clientY;
1704 sortUpdate(e.clientY);
1705 }
1706
1707 function sortOnEnd(e) {
1708 if (!sortDrag || e.pointerId !== sortDrag.pointerId) {
1709 return;
1710 }
1711 var d = sortDrag;
1712 var el = d.item;
1713 sortDrag = null;
1714
1715 window.cancelAnimationFrame(d.raf);
1716 window.removeEventListener("pointermove", sortOnMove);
1717 window.removeEventListener("pointerup", sortOnEnd);
1718 window.removeEventListener("pointercancel", sortOnEnd);
1719 document.body.classList.remove("wpsubs-sorting");
1720
1721 // Glide into the slot, then drop the lift.
1722 el.classList.remove("wpsubs-sort-item--dragging");
1723 el.classList.add("wpsubs-sort-item--settling");
1724 el.style.transform = "";
1725 window.setTimeout(function () {
1726 el.classList.remove("wpsubs-sort-item--settling");
1727 }, 240);
1728
1729 if (sortIds(d.list).join() !== d.before.join()) {
1730 sortSynced(d.list);
1731 sortSave(d.list, d.before);
1732 }
1733 }
1734
1735 document.addEventListener("pointerdown", function (e) {
1736 var handle = e.target.closest("[data-subscrpt-sort-handle]");
1737 if (!handle || sortDrag || (e.pointerType === "mouse" && 0 !== e.button)) {
1738 return;
1739 }
1740 var item = handle.closest("[data-subscrpt-sort-item]");
1741 var list = item && item.parentElement;
1742 if (!list || !list.hasAttribute("data-subscrpt-sortable")) {
1743 return;
1744 }
1745 e.preventDefault();
1746 handle.focus({ preventScroll: true });
1747
1748 sortDrag = {
1749 item: item,
1750 list: list,
1751 pointerId: e.pointerId,
1752 startY: e.clientY + window.scrollY,
1753 startTop: item.offsetTop,
1754 clientY: e.clientY,
1755 before: sortIds(list),
1756 };
1757
1758 item.style.transition = "";
1759 item.classList.remove("wpsubs-sort-item--settling");
1760 item.classList.add("wpsubs-sort-item--dragging");
1761 item.style.transform = "scale(1.01)";
1762 document.body.classList.add("wpsubs-sorting");
1763
1764 window.addEventListener("pointermove", sortOnMove, { passive: false });
1765 window.addEventListener("pointerup", sortOnEnd);
1766 window.addEventListener("pointercancel", sortOnEnd);
1767 sortDrag.raf = window.requestAnimationFrame(sortAutoScroll);
1768 });
1769
1770 // Keyboard: ArrowUp / ArrowDown on a focused handle moves one step.
1771 document.addEventListener("keydown", function (e) {
1772 var handle = e.target.closest ? e.target.closest("[data-subscrpt-sort-handle]") : null;
1773 if (!handle || sortDrag || ("ArrowUp" !== e.key && "ArrowDown" !== e.key)) {
1774 return;
1775 }
1776 var item = handle.closest("[data-subscrpt-sort-item]");
1777 var list = item && item.parentElement;
1778 if (!list || !list.hasAttribute("data-subscrpt-sortable")) {
1779 return;
1780 }
1781 e.preventDefault();
1782
1783 var items = sortVisible(list);
1784 var i = items.indexOf(item);
1785 var target = "ArrowUp" === e.key ? items[i - 1] : items[i + 1];
1786 if (!target) {
1787 return;
1788 }
1789 var before = sortIds(list);
1790 sortMove(list, item, "ArrowUp" === e.key ? target : target.nextSibling, true);
1791 handle.focus({ preventScroll: true });
1792 sortSynced(list);
1793 sortSave(list, before);
1794 });
1795
1796 /* ------------------------------------------------------------------ *
1797 * Products tab: client-side search + pagination over the product list.
1798 * ------------------------------------------------------------------ */
1799
1800 /**
1801 * Windowed page numbers with ellipses (first, last, current ±1).
1802 *
1803 * @param {number} current Current page.
1804 * @param {number} total Total pages.
1805 * @return {Array<number|string>}
1806 */
1807 function pageRange(current, total) {
1808 var out = [];
1809 for (var i = 1; i <= total; i++) {
1810 if (i === 1 || i === total || (i >= current - 1 && i <= current + 1)) {
1811 out.push(i);
1812 } else if (out[out.length - 1] !== "…") {
1813 out.push("…");
1814 }
1815 }
1816 return out;
1817 }
1818
1819 function initBrowser(root) {
1820 var perPage = parseInt(root.getAttribute("data-per-page"), 10) || 10;
1821 var input = root.querySelector("[data-subscrpt-browse-search]");
1822 var items = Array.prototype.slice.call(root.querySelectorAll("[data-subscrpt-browse-item]"));
1823 var emptyMsg = root.querySelector("[data-subscrpt-browse-empty]");
1824 var pager = root.querySelector("[data-subscrpt-browse-pager]");
1825 var term = "";
1826 var page = 1;
1827
1828 // Remember each item's own display so showing it restores that (e.g. cards
1829 // set display:flex inline) instead of clearing it to the block default.
1830 items.forEach(function (it) {
1831 it._subscrptDisplay = it.style.display || "";
1832 });
1833
1834 function match(item) {
1835 if (!term) {
1836 return true;
1837 }
1838 var name = (item.getAttribute("data-name") || "").indexOf(term) !== -1;
1839 var pid = String(item.getAttribute("data-pid") || "").indexOf(term) !== -1;
1840 return name || pid;
1841 }
1842
1843 function render() {
1844 var visible = items.filter(match);
1845 var total = Math.max(1, Math.ceil(visible.length / perPage));
1846 if (page > total) {
1847 page = total;
1848 }
1849 var start = (page - 1) * perPage;
1850 var pageItems = visible.slice(start, start + perPage);
1851
1852 items.forEach(function (it) {
1853 it.style.display = "none";
1854 });
1855 pageItems.forEach(function (it) {
1856 it.style.display = it._subscrptDisplay;
1857 });
1858
1859 if (emptyMsg) {
1860 emptyMsg.style.display = visible.length ? "none" : "";
1861 }
1862 renderPager(visible.length, total);
1863 }
1864
1865 function renderPager(count, total) {
1866 if (total <= 1) {
1867 pager.innerHTML = "";
1868 return;
1869 }
1870 var start = count ? (page - 1) * perPage + 1 : 0;
1871 var end = Math.min(page * perPage, count);
1872 var info = (i18n.showingRange || "Showing %1-%2 of %3")
1873 .replace("%1", start)
1874 .replace("%2", end)
1875 .replace("%3", count);
1876
1877 var nav = "";
1878 var chip = function (label, target, disabled, active) {
1879 var cls = "wpsubs-pagination__btn";
1880 if (disabled) {
1881 cls += " wpsubs-pagination__btn--disabled";
1882 }
1883 if (active) {
1884 cls += " wpsubs-pagination__btn--active";
1885 }
1886 if ("…" === label) {
1887 return '<span class="wpsubs-pagination__btn wpsubs-pagination__btn--ellipsis" aria-hidden="true">…</span>';
1888 }
1889 return '<button type="button" class="' + cls + '" data-subscrpt-page="' + target + '">' + label + "</button>";
1890 };
1891
1892 nav += chip("‹", page - 1, page <= 1, false);
1893 pageRange(page, total).forEach(function (p) {
1894 nav += "…" === p ? chip("…") : chip(p, p, false, p === page);
1895 });
1896 nav += chip("›", page + 1, page >= total, false);
1897
1898 pager.innerHTML =
1899 '<div class="wpsubs-pagination" role="navigation">' +
1900 '<span class="wpsubs-pagination__info">' +
1901 info +
1902 "</span>" +
1903 '<span class="wpsubs-pagination__nav">' +
1904 nav +
1905 "</span></div>";
1906 }
1907
1908 if (input) {
1909 var timer;
1910 input.addEventListener("input", function () {
1911 window.clearTimeout(timer);
1912 timer = window.setTimeout(function () {
1913 term = input.value.trim().toLowerCase();
1914 page = 1;
1915 render();
1916 }, 200);
1917 });
1918 }
1919
1920 // Per-page adv-select (fires wpsubs:select).
1921 document.addEventListener("wpsubs:select", function (e) {
1922 var sel = e.target.closest ? e.target.closest("[data-subscrpt-browse-perpage]") : null;
1923 if (!sel || !root.contains(sel)) {
1924 return;
1925 }
1926 perPage = parseInt(e.detail && e.detail.value, 10) || perPage;
1927 page = 1;
1928 render();
1929 });
1930
1931 // A drag-and-drop reorder moved items in the DOM: page in the new order.
1932 root.addEventListener("subscrpt:sorted", function () {
1933 items = Array.prototype.slice.call(root.querySelectorAll("[data-subscrpt-browse-item]"));
1934 });
1935
1936 pager.addEventListener("click", function (e) {
1937 var btn = e.target.closest("[data-subscrpt-page]");
1938 if (!btn) {
1939 return;
1940 }
1941 page = parseInt(btn.getAttribute("data-subscrpt-page"), 10) || 1;
1942 render();
1943 });
1944
1945 render();
1946 }
1947
1948 function initBrowsers() {
1949 document.querySelectorAll("[data-subscrpt-browse]").forEach(initBrowser);
1950 }
1951
1952 if (document.readyState === "loading") {
1953 document.addEventListener("DOMContentLoaded", initBrowsers);
1954 } else {
1955 initBrowsers();
1956 }
1957 })();
1958