PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.2.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.2.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.2.0, at assets/js/admin/plans.js

1,936 lines 64.7 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 /* ------------------------------------------------------------------ *
1475 * Drag-and-drop reorder: durations + products (vertical only).
1476 *
1477 * A drag starts on [data-subscrpt-sort-handle] only, moves its
1478 * [data-subscrpt-sort-item] within the [data-subscrpt-sortable] list, and
1479 * saves the list's full id order (hidden/paged-out items included) to
1480 * /groups/{id}/order. Siblings slide aside with a FLIP transition. Arrow
1481 * keys on a focused handle move it one step. Everything is delegated,
1482 * because the Products panel is re-rendered in place.
1483 * ------------------------------------------------------------------ */
1484
1485 var SORT_ANIM_MS = 200;
1486 var SORT_EDGE_PX = 60;
1487 var sortDrag = null;
1488 var sortPending = {};
1489
1490 /**
1491 * Items of a list the merchant can currently see (search/pager aware).
1492 *
1493 * @param {HTMLElement} list Sortable list.
1494 * @return {HTMLElement[]}
1495 */
1496 function sortVisible(list) {
1497 return Array.prototype.filter.call(list.children, function (el) {
1498 return el.hasAttribute("data-subscrpt-sort-item") && "none" !== el.style.display;
1499 });
1500 }
1501
1502 /**
1503 * Every item id of a list, in DOM order.
1504 *
1505 * @param {HTMLElement} list Sortable list.
1506 * @return {number[]}
1507 */
1508 function sortIds(list) {
1509 return Array.prototype.map.call(list.querySelectorAll(":scope > [data-subscrpt-sort-item]"), function (el) {
1510 return parseInt(el.getAttribute("data-subscrpt-sort-item"), 10);
1511 });
1512 }
1513
1514 /**
1515 * Move `el` in the DOM (before `ref`, or to the end) and slide every other
1516 * visible item from where it was to where it now is.
1517 *
1518 * @param {HTMLElement} list Sortable list.
1519 * @param {HTMLElement} el Item to move.
1520 * @param {HTMLElement|null} ref Insert before this item.
1521 * @param {boolean} self Animate `el` too (keyboard moves).
1522 */
1523 function sortMove(list, el, ref, self) {
1524 var others = sortVisible(list).filter(function (it) {
1525 return self || it !== el;
1526 });
1527 var before = others.map(function (it) {
1528 return it.offsetTop;
1529 });
1530
1531 list.insertBefore(el, ref);
1532
1533 others.forEach(function (it, i) {
1534 var delta = before[i] - it.offsetTop;
1535 if (!delta) {
1536 return;
1537 }
1538 it.style.transition = "none";
1539 it.style.transform = "translateY(" + delta + "px)";
1540 void it.offsetHeight; // Commit the start frame.
1541 it.style.transition = "transform " + SORT_ANIM_MS + "ms cubic-bezier(0.2, 0, 0, 1)";
1542 it.style.transform = "";
1543 window.clearTimeout(it._subscrptSortTimer);
1544 it._subscrptSortTimer = window.setTimeout(function () {
1545 it.style.transition = "";
1546 }, SORT_ANIM_MS);
1547 });
1548 }
1549
1550 /**
1551 * Put a list back into a given id order (a failed save).
1552 *
1553 * @param {HTMLElement} list Sortable list.
1554 * @param {number[]} ids Order to restore.
1555 */
1556 function sortRestore(list, ids) {
1557 ids.forEach(function (id) {
1558 var el = list.querySelector(':scope > [data-subscrpt-sort-item="' + id + '"]');
1559 if (el) {
1560 list.appendChild(el);
1561 }
1562 });
1563 sortSynced(list);
1564 }
1565
1566 /**
1567 * Tell the list's search/pager the DOM order changed.
1568 *
1569 * @param {HTMLElement} list Sortable list.
1570 */
1571 function sortSynced(list) {
1572 var root = list.closest("[data-subscrpt-browse]");
1573 if (root) {
1574 root.dispatchEvent(new CustomEvent("subscrpt:sorted"));
1575 }
1576 }
1577
1578 /**
1579 * Save a list's order. Debounced so a run of arrow-key moves is one write;
1580 * on failure the list goes back to the order it had before the run.
1581 *
1582 * @param {HTMLElement} list Sortable list.
1583 * @param {number[]} before Order before this change.
1584 */
1585 function sortSave(list, before) {
1586 var key = list.getAttribute("data-subscrpt-sortable");
1587 var groupId = currentGroupId();
1588 if (!key || !groupId) {
1589 return;
1590 }
1591
1592 var pending = sortPending[key] || { before: before };
1593 sortPending[key] = pending;
1594 window.clearTimeout(pending.timer);
1595 pending.timer = window.setTimeout(function () {
1596 delete sortPending[key];
1597 var ids = sortIds(list);
1598 if (ids.join() === pending.before.join()) {
1599 return;
1600 }
1601 var body = {};
1602 body[key] = ids;
1603 api("PUT", "/groups/" + groupId + "/order", body)
1604 .then(function () {
1605 save.notify(i18n.orderSaved);
1606 })
1607 .catch(function (err) {
1608 sortRestore(list, pending.before);
1609 save.notify(err.message || i18n.genericError, "error");
1610 });
1611 }, 300);
1612 }
1613
1614 /**
1615 * Follow the pointer: position the lifted item, swap it past any sibling
1616 * whose midpoint it crossed, and keep it inside the list.
1617 *
1618 * @param {number} clientY Pointer Y in the viewport.
1619 */
1620 function sortUpdate(clientY) {
1621 var d = sortDrag;
1622 var el = d.item;
1623 var items = sortVisible(d.list);
1624 var first = items[0];
1625 var last = items[items.length - 1];
1626
1627 // Page coordinates, so auto-scroll does not throw the item off the pointer.
1628 var dy = clientY + window.scrollY - d.startY;
1629 var top = d.startTop + dy;
1630 top = Math.max(first.offsetTop, Math.min(top, last.offsetTop + last.offsetHeight - el.offsetHeight));
1631 // Leading edge, not centre: an expanded (tall) product must still be able
1632 // to pass a short one inside the clamped range.
1633 var bottom = top + el.offsetHeight;
1634
1635 var i = items.indexOf(el);
1636 var next = items[i + 1];
1637 var prev = items[i - 1];
1638 var movedDown = false;
1639 while (next && bottom > next.offsetTop + next.offsetHeight / 2) {
1640 sortMove(d.list, el, next.nextSibling, false);
1641 items = sortVisible(d.list);
1642 next = items[items.indexOf(el) + 1];
1643 movedDown = true;
1644 }
1645 while (!movedDown && prev && top < prev.offsetTop + prev.offsetHeight / 2) {
1646 sortMove(d.list, el, prev, false);
1647 items = sortVisible(d.list);
1648 prev = items[items.indexOf(el) - 1];
1649 }
1650
1651 el.style.transform = "translateY(" + (top - el.offsetTop) + "px) scale(1.01)";
1652 }
1653
1654 /**
1655 * Scroll the page while the pointer rests near the top/bottom edge.
1656 */
1657 function sortAutoScroll() {
1658 if (!sortDrag) {
1659 return;
1660 }
1661 var y = sortDrag.clientY;
1662 var h = window.innerHeight;
1663 var step = 0;
1664 if (y < SORT_EDGE_PX) {
1665 step = -Math.ceil((SORT_EDGE_PX - y) / 4);
1666 } else if (y > h - SORT_EDGE_PX) {
1667 step = Math.ceil((y - (h - SORT_EDGE_PX)) / 4);
1668 }
1669 if (step) {
1670 window.scrollBy(0, step);
1671 sortUpdate(y);
1672 }
1673 sortDrag.raf = window.requestAnimationFrame(sortAutoScroll);
1674 }
1675
1676 function sortOnMove(e) {
1677 if (!sortDrag || e.pointerId !== sortDrag.pointerId) {
1678 return;
1679 }
1680 e.preventDefault();
1681 sortDrag.clientY = e.clientY;
1682 sortUpdate(e.clientY);
1683 }
1684
1685 function sortOnEnd(e) {
1686 if (!sortDrag || e.pointerId !== sortDrag.pointerId) {
1687 return;
1688 }
1689 var d = sortDrag;
1690 var el = d.item;
1691 sortDrag = null;
1692
1693 window.cancelAnimationFrame(d.raf);
1694 window.removeEventListener("pointermove", sortOnMove);
1695 window.removeEventListener("pointerup", sortOnEnd);
1696 window.removeEventListener("pointercancel", sortOnEnd);
1697 document.body.classList.remove("wpsubs-sorting");
1698
1699 // Glide into the slot, then drop the lift.
1700 el.classList.remove("wpsubs-sort-item--dragging");
1701 el.classList.add("wpsubs-sort-item--settling");
1702 el.style.transform = "";
1703 window.setTimeout(function () {
1704 el.classList.remove("wpsubs-sort-item--settling");
1705 }, 240);
1706
1707 if (sortIds(d.list).join() !== d.before.join()) {
1708 sortSynced(d.list);
1709 sortSave(d.list, d.before);
1710 }
1711 }
1712
1713 document.addEventListener("pointerdown", function (e) {
1714 var handle = e.target.closest("[data-subscrpt-sort-handle]");
1715 if (!handle || sortDrag || (e.pointerType === "mouse" && 0 !== e.button)) {
1716 return;
1717 }
1718 var item = handle.closest("[data-subscrpt-sort-item]");
1719 var list = item && item.parentElement;
1720 if (!list || !list.hasAttribute("data-subscrpt-sortable")) {
1721 return;
1722 }
1723 e.preventDefault();
1724 handle.focus({ preventScroll: true });
1725
1726 sortDrag = {
1727 item: item,
1728 list: list,
1729 pointerId: e.pointerId,
1730 startY: e.clientY + window.scrollY,
1731 startTop: item.offsetTop,
1732 clientY: e.clientY,
1733 before: sortIds(list),
1734 };
1735
1736 item.style.transition = "";
1737 item.classList.remove("wpsubs-sort-item--settling");
1738 item.classList.add("wpsubs-sort-item--dragging");
1739 item.style.transform = "scale(1.01)";
1740 document.body.classList.add("wpsubs-sorting");
1741
1742 window.addEventListener("pointermove", sortOnMove, { passive: false });
1743 window.addEventListener("pointerup", sortOnEnd);
1744 window.addEventListener("pointercancel", sortOnEnd);
1745 sortDrag.raf = window.requestAnimationFrame(sortAutoScroll);
1746 });
1747
1748 // Keyboard: ArrowUp / ArrowDown on a focused handle moves one step.
1749 document.addEventListener("keydown", function (e) {
1750 var handle = e.target.closest ? e.target.closest("[data-subscrpt-sort-handle]") : null;
1751 if (!handle || sortDrag || ("ArrowUp" !== e.key && "ArrowDown" !== e.key)) {
1752 return;
1753 }
1754 var item = handle.closest("[data-subscrpt-sort-item]");
1755 var list = item && item.parentElement;
1756 if (!list || !list.hasAttribute("data-subscrpt-sortable")) {
1757 return;
1758 }
1759 e.preventDefault();
1760
1761 var items = sortVisible(list);
1762 var i = items.indexOf(item);
1763 var target = "ArrowUp" === e.key ? items[i - 1] : items[i + 1];
1764 if (!target) {
1765 return;
1766 }
1767 var before = sortIds(list);
1768 sortMove(list, item, "ArrowUp" === e.key ? target : target.nextSibling, true);
1769 handle.focus({ preventScroll: true });
1770 sortSynced(list);
1771 sortSave(list, before);
1772 });
1773
1774 /* ------------------------------------------------------------------ *
1775 * Products tab: client-side search + pagination over the product list.
1776 * ------------------------------------------------------------------ */
1777
1778 /**
1779 * Windowed page numbers with ellipses (first, last, current ±1).
1780 *
1781 * @param {number} current Current page.
1782 * @param {number} total Total pages.
1783 * @return {Array<number|string>}
1784 */
1785 function pageRange(current, total) {
1786 var out = [];
1787 for (var i = 1; i <= total; i++) {
1788 if (i === 1 || i === total || (i >= current - 1 && i <= current + 1)) {
1789 out.push(i);
1790 } else if (out[out.length - 1] !== "…") {
1791 out.push("…");
1792 }
1793 }
1794 return out;
1795 }
1796
1797 function initBrowser(root) {
1798 var perPage = parseInt(root.getAttribute("data-per-page"), 10) || 10;
1799 var input = root.querySelector("[data-subscrpt-browse-search]");
1800 var items = Array.prototype.slice.call(root.querySelectorAll("[data-subscrpt-browse-item]"));
1801 var emptyMsg = root.querySelector("[data-subscrpt-browse-empty]");
1802 var pager = root.querySelector("[data-subscrpt-browse-pager]");
1803 var term = "";
1804 var page = 1;
1805
1806 // Remember each item's own display so showing it restores that (e.g. cards
1807 // set display:flex inline) instead of clearing it to the block default.
1808 items.forEach(function (it) {
1809 it._subscrptDisplay = it.style.display || "";
1810 });
1811
1812 function match(item) {
1813 if (!term) {
1814 return true;
1815 }
1816 var name = (item.getAttribute("data-name") || "").indexOf(term) !== -1;
1817 var pid = String(item.getAttribute("data-pid") || "").indexOf(term) !== -1;
1818 return name || pid;
1819 }
1820
1821 function render() {
1822 var visible = items.filter(match);
1823 var total = Math.max(1, Math.ceil(visible.length / perPage));
1824 if (page > total) {
1825 page = total;
1826 }
1827 var start = (page - 1) * perPage;
1828 var pageItems = visible.slice(start, start + perPage);
1829
1830 items.forEach(function (it) {
1831 it.style.display = "none";
1832 });
1833 pageItems.forEach(function (it) {
1834 it.style.display = it._subscrptDisplay;
1835 });
1836
1837 if (emptyMsg) {
1838 emptyMsg.style.display = visible.length ? "none" : "";
1839 }
1840 renderPager(visible.length, total);
1841 }
1842
1843 function renderPager(count, total) {
1844 if (total <= 1) {
1845 pager.innerHTML = "";
1846 return;
1847 }
1848 var start = count ? (page - 1) * perPage + 1 : 0;
1849 var end = Math.min(page * perPage, count);
1850 var info = (i18n.showingRange || "Showing %1-%2 of %3")
1851 .replace("%1", start)
1852 .replace("%2", end)
1853 .replace("%3", count);
1854
1855 var nav = "";
1856 var chip = function (label, target, disabled, active) {
1857 var cls = "wpsubs-pagination__btn";
1858 if (disabled) {
1859 cls += " wpsubs-pagination__btn--disabled";
1860 }
1861 if (active) {
1862 cls += " wpsubs-pagination__btn--active";
1863 }
1864 if ("…" === label) {
1865 return '<span class="wpsubs-pagination__btn wpsubs-pagination__btn--ellipsis" aria-hidden="true">…</span>';
1866 }
1867 return '<button type="button" class="' + cls + '" data-subscrpt-page="' + target + '">' + label + "</button>";
1868 };
1869
1870 nav += chip("‹", page - 1, page <= 1, false);
1871 pageRange(page, total).forEach(function (p) {
1872 nav += "…" === p ? chip("…") : chip(p, p, false, p === page);
1873 });
1874 nav += chip("›", page + 1, page >= total, false);
1875
1876 pager.innerHTML =
1877 '<div class="wpsubs-pagination" role="navigation">' +
1878 '<span class="wpsubs-pagination__info">' +
1879 info +
1880 "</span>" +
1881 '<span class="wpsubs-pagination__nav">' +
1882 nav +
1883 "</span></div>";
1884 }
1885
1886 if (input) {
1887 var timer;
1888 input.addEventListener("input", function () {
1889 window.clearTimeout(timer);
1890 timer = window.setTimeout(function () {
1891 term = input.value.trim().toLowerCase();
1892 page = 1;
1893 render();
1894 }, 200);
1895 });
1896 }
1897
1898 // Per-page adv-select (fires wpsubs:select).
1899 document.addEventListener("wpsubs:select", function (e) {
1900 var sel = e.target.closest ? e.target.closest("[data-subscrpt-browse-perpage]") : null;
1901 if (!sel || !root.contains(sel)) {
1902 return;
1903 }
1904 perPage = parseInt(e.detail && e.detail.value, 10) || perPage;
1905 page = 1;
1906 render();
1907 });
1908
1909 // A drag-and-drop reorder moved items in the DOM: page in the new order.
1910 root.addEventListener("subscrpt:sorted", function () {
1911 items = Array.prototype.slice.call(root.querySelectorAll("[data-subscrpt-browse-item]"));
1912 });
1913
1914 pager.addEventListener("click", function (e) {
1915 var btn = e.target.closest("[data-subscrpt-page]");
1916 if (!btn) {
1917 return;
1918 }
1919 page = parseInt(btn.getAttribute("data-subscrpt-page"), 10) || 1;
1920 render();
1921 });
1922
1923 render();
1924 }
1925
1926 function initBrowsers() {
1927 document.querySelectorAll("[data-subscrpt-browse]").forEach(initBrowser);
1928 }
1929
1930 if (document.readyState === "loading") {
1931 document.addEventListener("DOMContentLoaded", initBrowsers);
1932 } else {
1933 initBrowsers();
1934 }
1935 })();
1936