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 / product-plans.js

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

1,288 lines 45.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Product editor — plan view (free, simple products).
3 *
4 * Toggles between the plan view and the classic settings, and connects /
5 * detaches the product to a Recurring plan group via the wpsubscription/v1
6 * REST API. Relies on the shared admin components (adv-select) being enqueued.
7 */
8 (function () {
9 "use strict";
10
11 var cfg = window.subscrptProductPlans || {};
12 var i18n = cfg.i18n || {};
13
14 // Step 1 group details captured while the wizard is open. The group is not
15 // created until step 2 (the plan) is submitted, so abandoning either step
16 // creates nothing.
17 var pendingGroup = null;
18
19 // Set when adding a plan to an already-existing (empty) group, so the term
20 // modal creates a plan against it instead of running the new-group wizard.
21 var pendingExistingGroupId = "";
22
23 // REST calls and every message here come from the shared component
24 // (admin-components/save.js).
25 var save = window.WPSubsSave.bind({ restUrl: cfg.restUrl, nonce: cfg.nonce, i18n: i18n });
26 var api = save.api;
27
28 function root() {
29 return document.querySelector("[data-subscrpt-product-plans]");
30 }
31
32 /* ------------------------------------------------------------------ *
33 * "Enable subscription" gates the rest of the tab.
34 * ------------------------------------------------------------------ */
35
36 /**
37 * Everything the toggle governs: each region beside the toolbar on the way
38 * up to the panel. Walking rather than naming selectors means whatever Pro
39 * adds to this panel is gated too, without it having to know about this.
40 *
41 * @param {HTMLElement} toggle The "Enable subscription" checkbox.
42 * @return {HTMLElement[]} Regions to switch off.
43 */
44 function gatedRegions(toggle) {
45 var toolbar = toggle.closest("[data-subscrpt-plan-toolbar]");
46 var panel = toggle.closest(".woocommerce_options_panel") || toggle.closest("#sdevs_subscription_options");
47 if (!toolbar || !panel) {
48 return [];
49 }
50
51 var regions = [];
52 var node = toolbar;
53 while (node && node !== panel && node.parentNode) {
54 var sibling = node.parentNode.firstElementChild;
55 while (sibling) {
56 // Modals are overlays, not settings — never gate them.
57 if (sibling !== node && !sibling.classList.contains("wpsubs-modal")) {
58 regions.push(sibling);
59 }
60 sibling = sibling.nextElementSibling;
61 }
62 node = node.parentNode;
63 }
64 return regions;
65 }
66
67 /**
68 * Match the settings to the toggle.
69 *
70 * `inert` rather than `disabled`: these are product meta fields that post
71 * with the product form, and a disabled field posts nothing — the stored
72 * values would be wiped on the next save of a product whose subscription is
73 * merely switched off.
74 */
75 function syncSubscriptionGate() {
76 var toggle = document.getElementById("subscrpt_enable");
77 if (!toggle) {
78 // Variable products enable per variation; there is no product-level
79 // toggle to gate on.
80 return;
81 }
82 var off = !toggle.checked;
83 gatedRegions(toggle).forEach(function (el) {
84 el.classList.toggle("subscrpt-gated", off);
85 if ("inert" in el) {
86 el.inert = off;
87 }
88 });
89 }
90
91 document.addEventListener("change", function (e) {
92 if (e.target && "subscrpt_enable" === e.target.id) {
93 syncSubscriptionGate();
94 }
95 });
96
97 // WooCommerce builds the product data panels before this runs on a normal
98 // load, but the tab is also rendered into an already-open page.
99 if ("loading" === document.readyState) {
100 document.addEventListener("DOMContentLoaded", syncSubscriptionGate);
101 } else {
102 syncSubscriptionGate();
103 }
104
105 /**
106 * The plan view's "Allow User Cancellation" toggle has no name of its own:
107 * the classic `subscrpt_user_cancel` select is what the product save reads.
108 * The two are kept in step both ways, so Update posts the value last set in
109 * whichever view the merchant used.
110 */
111 function syncPlanUserCancel() {
112 var select = document.querySelector('select[name="subscrpt_user_cancel"]');
113 var toggle = document.querySelector("[data-subscrpt-plan-user-cancel]");
114 if (select && toggle) {
115 toggle.checked = "no" !== select.value;
116 }
117 }
118
119 document.addEventListener("change", function (e) {
120 var target = e.target;
121 if (!target) {
122 return;
123 }
124 if (target.hasAttribute("data-subscrpt-plan-user-cancel")) {
125 var select = document.querySelector('select[name="subscrpt_user_cancel"]');
126 if (select) {
127 select.value = target.checked ? "yes" : "no";
128 }
129 } else if ("subscrpt_user_cancel" === target.name) {
130 syncPlanUserCancel();
131 }
132 });
133
134 /**
135 * The classic-settings pane for a plan-view wrapper. Free renders its own
136 * ([data-subscrpt-classic-view]); with Pro active the classic pane is Pro's
137 * `.subscrpt-classic-fields` sibling inside the panel.
138 *
139 * @param {HTMLElement} el The [data-subscrpt-product-plans] wrapper.
140 * @return {HTMLElement|null}
141 */
142 function classicPane(el) {
143 var panel = el.closest("#sdevs_subscription_options") || el;
144 return panel.querySelector("[data-subscrpt-classic-view]") || panel.querySelector(".subscrpt-classic-fields");
145 }
146
147 /**
148 * Show either the plan view or the classic settings.
149 *
150 * @param {HTMLElement} el The wrapper.
151 * @param {boolean} showClassic Whether classic settings are shown.
152 */
153 function setView(el, showClassic) {
154 var planView = el.querySelector("[data-subscrpt-plan-view]");
155 var classic = classicPane(el);
156 if (planView) {
157 planView.style.display = showClassic ? "none" : "";
158 }
159 if (classic) {
160 classic.style.display = showClassic ? "" : "none";
161 }
162 var showClassicBtn = el.querySelector("[data-subscrpt-show-classic]");
163 var showPlanBtn = el.querySelector("[data-subscrpt-show-plan]");
164 if (showClassicBtn) {
165 showClassicBtn.style.display = showClassic ? "none" : "";
166 }
167 if (showPlanBtn) {
168 showPlanBtn.style.display = showClassic ? "" : "none";
169 }
170 }
171
172 // View toggle.
173 document.addEventListener("click", function (e) {
174 var toClassic = e.target.closest("[data-subscrpt-show-classic]");
175 var toPlan = e.target.closest("[data-subscrpt-show-plan]");
176 if (!toClassic && !toPlan) {
177 return;
178 }
179 var el = root();
180 if (el) {
181 setView(el, !!toClassic);
182 }
183 });
184
185 /**
186 * Wire the "Generate Checkout Link" modal: read the adv-select choices (link
187 * type / variation / plan), live-preview the composed link, and copy it. Plan
188 * options are one adv-select per variation; only the active one is shown.
189 */
190 function setupCheckoutLink() {
191 var modal = document.getElementById("subscrpt-checkout-link");
192 if (!modal) {
193 return;
194 }
195 var data;
196 try {
197 data = JSON.parse(modal.getAttribute("data-subscrpt-checkout-data") || "{}");
198 } catch (err) {
199 return;
200 }
201 if (!data.contexts || !data.contexts.length) {
202 return;
203 }
204
205 // Ensure adv-selects inside the (relocated) modal are initialised.
206 if (window.WPSubsAdvSelect) {
207 window.WPSubsAdvSelect.init(modal);
208 }
209
210 var typeInput = modal.querySelector("[data-subscrpt-checkout-type] input[type=hidden]");
211 var varAdv = modal.querySelector("[data-subscrpt-checkout-variation]");
212 var varInput = varAdv ? varAdv.querySelector("input[type=hidden]") : null;
213 var planWraps = modal.querySelectorAll("[data-subscrpt-checkout-plan-wrap]");
214 var preview = modal.querySelector("[data-subscrpt-checkout-preview]");
215 var warning = modal.querySelector("[data-subscrpt-checkout-warning]");
216 var refCart = modal.querySelector('[data-subscrpt-checkout-ref="cart"]');
217 var refCheckout = modal.querySelector('[data-subscrpt-checkout-ref="checkout"]');
218 var copyBtns = modal.querySelectorAll("[data-subscrpt-checkout-copy]");
219
220 function activeVid() {
221 if (varInput && varInput.value !== "") {
222 return varInput.value;
223 }
224 return String(data.contexts[0].vid);
225 }
226
227 function contextByVid(vid) {
228 for (var i = 0; i < data.contexts.length; i++) {
229 if (String(data.contexts[i].vid) === String(vid)) {
230 return data.contexts[i];
231 }
232 }
233 return data.contexts[0];
234 }
235
236 function activePlanValue() {
237 var vid = activeVid();
238 var value = "";
239 planWraps.forEach(function (w) {
240 if (String(w.getAttribute("data-vid")) === String(vid)) {
241 var input = w.querySelector("input[type=hidden]");
242 value = input ? input.value : "";
243 }
244 });
245 return value;
246 }
247
248 function showActivePlanWrap() {
249 var vid = activeVid();
250 planWraps.forEach(function (w) {
251 w.hidden = String(w.getAttribute("data-vid")) !== String(vid);
252 });
253 }
254
255 function append(base, params) {
256 return base + (base.indexOf("?") === -1 ? "?" : "&") + params.join("&");
257 }
258
259 function buildLink() {
260 var ctx = contextByVid(activeVid());
261 var plan = activePlanValue();
262 var planParam = plan !== "" && plan !== "onetime" ? "subscrpt_plan_id=" + encodeURIComponent(plan) : "";
263
264 if (typeInput && typeInput.value === "checkout") {
265 // Native WooCommerce checkout-link: /checkout-link/?products=ID:QTY .
266 // For variations the target is the variation id itself.
267 var target = data.type === "variable" ? ctx.vid : data.productId;
268 var checkoutParams = ["products=" + encodeURIComponent(target + ":1")];
269 if (planParam) {
270 checkoutParams.push(planParam);
271 }
272 return append(data.checkoutLinkBase, checkoutParams);
273 }
274
275 // Classic add-to-cart link on the site root.
276 var params = ["add-to-cart=" + encodeURIComponent(data.productId)];
277 if (data.type === "variable") {
278 params.push("variation_id=" + encodeURIComponent(ctx.vid));
279 var attrs = ctx.attrs || {};
280 Object.keys(attrs).forEach(function (k) {
281 params.push(encodeURIComponent(k) + "=" + encodeURIComponent(attrs[k]));
282 });
283 }
284 if (planParam) {
285 params.push(planParam);
286 }
287 return append(data.cartBase, params);
288 }
289
290 function refresh() {
291 var ctx = contextByVid(activeVid());
292 var isCheckout = typeInput && typeInput.value === "checkout";
293 if (warning) {
294 warning.style.display = isCheckout && ctx && ctx.hasAny ? "flex" : "none";
295 }
296 if (refCart) {
297 refCart.hidden = isCheckout;
298 }
299 if (refCheckout) {
300 refCheckout.hidden = !isCheckout;
301 }
302 if (preview) {
303 preview.value = buildLink();
304 }
305 }
306
307 showActivePlanWrap();
308 refresh();
309
310 // adv-selects fire a bubbling "wpsubs:select" — recompute on any choice, and
311 // re-toggle the plan adv-selects when the variation changes.
312 modal.addEventListener("wpsubs:select", function (e) {
313 if (varAdv && varAdv.contains(e.target)) {
314 showActivePlanWrap();
315 }
316 refresh();
317 });
318
319 copyBtns.forEach(function (btn) {
320 btn.addEventListener("click", function () {
321 var link = preview ? preview.value : "";
322 if (!link) {
323 return;
324 }
325 var done = function () {
326 var label = btn.querySelector(".dashicons");
327 if (label) {
328 var prev = label.className;
329 label.className = "dashicons dashicons-yes";
330 setTimeout(function () {
331 label.className = prev;
332 }, 1500);
333 }
334 };
335 if (navigator.clipboard && navigator.clipboard.writeText) {
336 navigator.clipboard.writeText(link).then(done, function () {
337 if (preview) {
338 preview.select();
339 }
340 window.prompt(i18n.copyLinkPrompt || "Copy this link:", link);
341 });
342 } else {
343 if (preview) {
344 preview.select();
345 }
346 window.prompt(i18n.copyLinkPrompt || "Copy this link:", link);
347 }
348 });
349 });
350 }
351
352 // Per-plan "Copy checkout link": copies the direct checkout link for that plan
353 // (WooCommerce checkout-link endpoint + subscrpt_plan_id — not an add-to-cart
354 // link). Reuses the checkout-link data blob for the base URL + product id.
355 document.addEventListener("click", function (e) {
356 var btn = e.target.closest("[data-subscrpt-copy-checkout]");
357 if (!btn) {
358 return;
359 }
360 e.preventDefault();
361
362 var modal = document.getElementById("subscrpt-checkout-link");
363 var data;
364 try {
365 data = JSON.parse((modal && modal.getAttribute("data-subscrpt-checkout-data")) || "{}");
366 } catch (err) {
367 return;
368 }
369 if (!data.checkoutLinkBase) {
370 return;
371 }
372
373 var planId = btn.getAttribute("data-plan-id");
374 var vid = btn.getAttribute("data-vid");
375 var target = "variable" === data.type && vid && "0" !== vid ? vid : data.productId;
376 var base = data.checkoutLinkBase;
377 // One-time purchase carries no plan; the storefront resolves it without a
378 // subscrpt_plan_id (matches the "onetime" sentinel in the checkout modal).
379 var planParam = planId && "onetime" !== planId ? "&subscrpt_plan_id=" + encodeURIComponent(planId) : "";
380 var link =
381 base + (base.indexOf("?") === -1 ? "?" : "&") + "products=" + encodeURIComponent(target + ":1") + planParam;
382
383 // Flash a check on the icon to confirm the copy.
384 var done = function () {
385 var icon = btn.querySelector(".dashicons");
386 if (icon) {
387 var prev = icon.className;
388 icon.className = "dashicons dashicons-yes";
389 setTimeout(function () {
390 icon.className = prev;
391 }, 1500);
392 }
393 };
394
395 if (navigator.clipboard && navigator.clipboard.writeText) {
396 navigator.clipboard.writeText(link).then(done, function () {
397 window.prompt(i18n.copyLinkPrompt || "Copy this link:", link);
398 });
399 } else {
400 window.prompt(i18n.copyLinkPrompt || "Copy this link:", link);
401 }
402 });
403
404 // On load, default to the plan view — unless the product carries legacy
405 // classic settings (data-subscrpt-default-classic), then open classic mode.
406 function init() {
407 var el = root();
408 if (el) {
409 setView(el, el.hasAttribute("data-subscrpt-default-classic"));
410 }
411 detachModals();
412 setupCheckoutLink();
413 setupWizard();
414 }
415
416 /**
417 * Move the plan modals out of the WooCommerce product panel to <body>.
418 *
419 * Rendered in place, they inherit `.woocommerce_options_panel` descendant
420 * styles (floated labels, fixed input widths) that break their layout. On
421 * the Plans page the same modals sit at top level; relocating here matches
422 * that so they render identically. IDs and delegated open/close still work.
423 */
424 function detachModals() {
425 ["subscrpt-create-plan", "subscrpt-term-modal", "subscrpt-checkout-link"].forEach(function (id) {
426 var modal = document.getElementById(id);
427 if (modal && modal.parentNode !== document.body) {
428 document.body.appendChild(modal);
429 }
430 });
431 }
432
433 if (document.readyState === "loading") {
434 document.addEventListener("DOMContentLoaded", init);
435 } else {
436 init();
437 }
438
439 // Picking a plan group reveals its price table (one block per group).
440 // The block's "Connect" button reuses the shared save handler below.
441 document.addEventListener("wpsubs:select", function (e) {
442 var sel = e.target.closest ? e.target.closest("[data-subscrpt-connect-group]") : null;
443 if (!sel) {
444 return;
445 }
446 var card = sel.closest("[data-subscrpt-connect-card]");
447 var groupId = String(parseInt(e.detail && e.detail.value, 10) || 0);
448
449 // Prefill the plan's regular prices from WooCommerce's regular price field
450 // (product edit page only — that field does not exist elsewhere). Only fills
451 // inputs left empty, so a saved or manually-entered price is never overwritten.
452 var regularField = document.getElementById("_regular_price");
453 var regularVal = regularField ? String(regularField.value).trim() : "";
454
455 card.querySelectorAll("[data-connect-block]").forEach(function (block) {
456 var on = block.getAttribute("data-group-id") === groupId;
457 block.style.display = on ? "block" : "none";
458 if (!on) {
459 return;
460 }
461 if (regularVal !== "") {
462 block.querySelectorAll('[data-field="regular_price"]').forEach(function (input) {
463 if (!String(input.value).trim()) {
464 input.value = regularVal;
465 }
466 });
467 }
468 // The one-time price is the same product price, so seed it from the same
469 // place — otherwise the block offers prices for every duration and a
470 // blank for buying it once.
471 if (window.WPSubsPlanForms && window.WPSubsPlanForms.seedOneTimePrices) {
472 block.querySelectorAll("[data-subscrpt-onetime]").forEach(function (row) {
473 window.WPSubsPlanForms.seedOneTimePrices(row);
474 });
475 }
476 });
477 var divider = card.querySelector("[data-subscrpt-connect-divider]");
478 if (divider) {
479 divider.style.display = "0" === groupId ? "none" : "";
480 }
481 // Reveal the one-time purchase card alongside the picked group's prices
482 // (simple products: it sits above the connect card while not yet connected).
483 var wrap = document.querySelector("[data-subscrpt-onetime-wrap]");
484 if (wrap) {
485 wrap.style.display = "0" === groupId ? "none" : "";
486 }
487
488 // Picking a plan means this product is a subscription — turn the Enable
489 // subscription toggle on if it is still off (the :checked sibling styles the
490 // switch; no change event, so the classic-settings pane is left as-is).
491 if ("0" !== groupId) {
492 var enableToggle = document.getElementById("subscrpt_enable");
493 if (enableToggle && !enableToggle.checked) {
494 enableToggle.checked = true;
495 }
496 }
497 });
498
499 /* ------------------------------------------------------------------ *
500 * Two-step wizard: create a plan group + its first plan without leaving the
501 * editor (Pro). "+ New" opens step 1 (group), which advances to step 2
502 * (plan). Neither is written until step 2 is submitted; then both are created
503 * atomically and the plan view refreshes in place (unsaved product edits
504 * survive). plan-forms.js owns the field markup + collection; the wizard
505 * chrome (steps, Back, deferral) is layered on here.
506 * ------------------------------------------------------------------ */
507
508 /**
509 * Turn the shared modals into a two-step wizard: mark them deferred so
510 * plan-forms.js hands values back instead of writing, add a step badge, and
511 * relabel the primary buttons (plus a Back control on step 2).
512 */
513 function setupWizard() {
514 var create = document.getElementById("subscrpt-create-plan");
515 var term = document.getElementById("subscrpt-term-modal");
516
517 // Rendered inside WooCommerce's product-data panel, whose stacking context
518 // traps the fixed modals. Move them to <body> so they overlay correctly.
519 [create, term].forEach(function (m) {
520 if (m && m.parentNode !== document.body) {
521 document.body.appendChild(m);
522 }
523 });
524
525 if (create && !create.hasAttribute("data-subscrpt-defer")) {
526 create.setAttribute("data-subscrpt-defer", "1");
527 injectStep(create, i18n.step1 || "Step 1 of 2");
528 var cbtn = create.querySelector("[data-subscrpt-create-plan]");
529 if (cbtn) {
530 cbtn.textContent = i18n.wizardNext || "Continue";
531 }
532 }
533
534 if (term && !term.hasAttribute("data-subscrpt-defer")) {
535 term.setAttribute("data-subscrpt-defer", "1");
536 injectStep(term, i18n.step2 || "Step 2 of 2");
537 var sbtn = term.querySelector("[data-subscrpt-term-submit]");
538 if (sbtn) {
539 sbtn.textContent = i18n.wizardCreate || "Create";
540 if (!term.querySelector("[data-subscrpt-wizard-back]")) {
541 var back = document.createElement("button");
542 back.type = "button";
543 back.className = "wpsubs-btn wpsubs-btn--outline";
544 back.setAttribute("data-subscrpt-wizard-back", "1");
545 back.textContent = i18n.wizardBack || "Back";
546 sbtn.parentNode.insertBefore(back, sbtn);
547 }
548 }
549 }
550 }
551
552 /**
553 * Prepend a "Step N of 2" badge above a modal's title.
554 *
555 * @param {HTMLElement} modal The modal element.
556 * @param {string} text Badge text.
557 */
558 function injectStep(modal, text) {
559 var title = modal.querySelector(".wpsubs-modal__title");
560 if (!title || (title.parentNode && title.parentNode.querySelector(".subscrpt-wizard-step"))) {
561 return;
562 }
563 var badge = document.createElement("div");
564 badge.className = "subscrpt-wizard-step";
565 badge.textContent = text;
566 badge.style.cssText =
567 "font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--wpsubs-brand,#ff4d00);margin:0 0 6px;";
568 var parent = title.parentNode;
569 if (parent && parent.classList.contains("wpsubs-modal__head")) {
570 // Term modal: the title sits directly in the head (a flex row). Wrap it so
571 // the badge can sit on its own line above the title.
572 var col = document.createElement("div");
573 parent.insertBefore(col, title);
574 col.appendChild(badge);
575 col.appendChild(title);
576 } else {
577 // Create modal: the title is already inside a text column.
578 parent.insertBefore(badge, parent.firstChild);
579 }
580 }
581
582 // Starting fresh via "+ New": clear any stashed step-1 details and reset the
583 // create modal (Back uses a different control, so it keeps its values).
584 document.addEventListener("click", function (e) {
585 if (!e.target.closest('[data-wpsubs-modal-open="subscrpt-create-plan"]')) {
586 return;
587 }
588 pendingGroup = null;
589 pendingExistingGroupId = "";
590 var nameInput = document.getElementById("subscrpt-create-name");
591 if (nameInput) {
592 nameInput.value = "";
593 }
594 var recurring = document.querySelector('.subscrpt-type-card[data-subscrpt-type="recurring"]');
595 if (recurring && !recurring.classList.contains("is-selected")) {
596 recurring.click();
597 }
598 });
599
600 /**
601 * Show/hide the term modal's wizard chrome. In "wizard" mode it is step 2 of
602 * new-group creation (step badge + Back). In "single" mode it just adds a
603 * plan to an existing group (no step badge, no Back).
604 *
605 * @param {string} mode "wizard" | "single".
606 */
607 function setTermModalMode(mode) {
608 var term = document.getElementById("subscrpt-term-modal");
609 if (!term) {
610 return;
611 }
612 var wizard = mode === "wizard";
613 var step = term.querySelector(".subscrpt-wizard-step");
614 if (step) {
615 step.style.display = wizard ? "" : "none";
616 }
617 var back = term.querySelector("[data-subscrpt-wizard-back]");
618 if (back) {
619 back.style.display = wizard ? "" : "none";
620 }
621 var submit = term.querySelector("[data-subscrpt-term-submit]");
622 if (submit) {
623 submit.textContent = wizard ? i18n.wizardCreate || "Create" : i18n.addPlan || "Add plan";
624 }
625 }
626
627 // "Create plan" on an empty group: add a plan directly to that group.
628 document.addEventListener("click", function (e) {
629 var btn = e.target.closest("[data-subscrpt-create-plan-for]");
630 if (!btn) {
631 return;
632 }
633 pendingGroup = null;
634 pendingExistingGroupId = btn.getAttribute("data-subscrpt-create-plan-for");
635 setTermModalMode("single");
636 if (window.WPSubsPlanForms && window.WPSubsPlanForms.openTermModalForGroup) {
637 window.WPSubsPlanForms.openTermModalForGroup(pendingExistingGroupId, "recurring");
638 }
639 });
640
641 // Step 1 submitted: stash the group details and advance to step 2 (the plan).
642 document.addEventListener("subscrpt:group-step", function (e) {
643 var d = e.detail || {};
644 pendingGroup = { title: d.title, type: d.type || "recurring" };
645 pendingExistingGroupId = "";
646 setTermModalMode("wizard");
647 if (window.WPSubsPlanForms && window.WPSubsPlanForms.openTermModalForGroup) {
648 // Group id 0: it does not exist yet; created on step-2 submit below.
649 window.WPSubsPlanForms.openTermModalForGroup(0, pendingGroup.type);
650 }
651 });
652
653 // Back on step 2: return to step 1 with the entered group details restored.
654 document.addEventListener("click", function (e) {
655 if (!e.target.closest("[data-subscrpt-wizard-back]")) {
656 return;
657 }
658 if (window.WPSubsPlanForms) {
659 window.WPSubsPlanForms.closeModal("subscrpt-term-modal");
660 window.WPSubsPlanForms.openModal("subscrpt-create-plan");
661 }
662 if (pendingGroup) {
663 var nameInput = document.getElementById("subscrpt-create-name");
664 if (nameInput) {
665 nameInput.value = pendingGroup.title || "";
666 }
667 var card = document.querySelector('.subscrpt-type-card[data-subscrpt-type="' + pendingGroup.type + '"]');
668 if (card && !card.classList.contains("is-selected")) {
669 card.click();
670 }
671 }
672 });
673
674 // Step 2 submitted. New-group wizard: create the group, then its first plan,
675 // atomically. Existing empty group: just create the plan against it.
676 document.addEventListener("subscrpt:term-step", function (e) {
677 var payload = e.detail && e.detail.payload;
678 if (!payload) {
679 return;
680 }
681
682 if (pendingGroup) {
683 var group = pendingGroup;
684 api("POST", "/groups", {
685 title: group.title,
686 type: group.type,
687 product_type: 1,
688 status: "active",
689 // This wizard creates its own first duration below; skip the auto-seed.
690 seed_default_term: false,
691 })
692 .then(function (created) {
693 pendingGroup = null;
694 var termBody = Object.assign({}, payload, {
695 plan_group_id: created.id,
696 type: group.type,
697 });
698 return api("POST", "/terms", termBody).then(function () {
699 refreshPlanView(String(created.id));
700 });
701 })
702 .catch(function (err) {
703 save.notify((err && err.message) || i18n.connectError, "error");
704 });
705 return;
706 }
707
708 if (pendingExistingGroupId) {
709 var gid = pendingExistingGroupId;
710 pendingExistingGroupId = "";
711 var body = Object.assign({}, payload, { plan_group_id: parseInt(gid, 10) });
712 api("POST", "/terms", body)
713 .then(function () {
714 refreshPlanView(String(gid));
715 })
716 .catch(function (err) {
717 save.notify((err && err.message) || i18n.connectError, "error");
718 });
719 }
720 });
721
722 /**
723 * Re-fetch the server-rendered plan view and swap it in place, then re-init
724 * the injected adv-selects and (optionally) select a connect group.
725 *
726 * @param {string} [selectGroupId] Group to auto-select after refresh.
727 */
728 function refreshPlanView(selectGroupId) {
729 var wrap = root();
730 if (!wrap) {
731 return;
732 }
733 var view = wrap.querySelector("[data-subscrpt-plan-view]");
734 var productId = parseInt(wrap.getAttribute("data-product-id"), 10);
735 if (!view || !productId) {
736 return;
737 }
738 api("GET", "/product-view/" + productId)
739 .then(function (res) {
740 view.innerHTML = res.html || "";
741 if (window.WPSubsAdvSelect && window.WPSubsAdvSelect.init) {
742 window.WPSubsAdvSelect.init(view);
743 }
744 syncSubscriptionGate();
745 // The server renders the stored value; an unsaved change lives in the
746 // classic select.
747 syncPlanUserCancel();
748 if (selectGroupId) {
749 selectConnectGroup(view, selectGroupId);
750 }
751 })
752 .catch(function (err) {
753 save.notify((err && err.message) || i18n.connectError, "error");
754 });
755 }
756
757 /**
758 * Select a connect group in the refreshed view and reveal its price table.
759 *
760 * @param {HTMLElement} scope The plan-view container.
761 * @param {string} groupId Group id to select.
762 */
763 function selectConnectGroup(scope, groupId) {
764 var card = scope.querySelector("[data-subscrpt-connect-card]");
765 if (!card) {
766 return;
767 }
768 var sel = card.querySelector("[data-subscrpt-connect-group]");
769 if (sel) {
770 var hidden = sel.querySelector('input[type="hidden"]');
771 if (hidden) {
772 hidden.value = groupId;
773 }
774 var label = sel.querySelector(".wpsubs-adv-select__label");
775 var item = sel.querySelector('.wpsubs-adv-select__item[data-value="' + groupId + '"]');
776 if (label && item) {
777 label.textContent = item.textContent.trim();
778 }
779 }
780 card.querySelectorAll("[data-connect-block]").forEach(function (block) {
781 block.style.display = block.getAttribute("data-group-id") === String(groupId) ? "block" : "none";
782 });
783 var divider = card.querySelector("[data-subscrpt-connect-divider]");
784 if (divider) {
785 divider.style.display = "";
786 }
787 }
788
789 // Detach the product from a plan group (delete all its relations).
790 document.addEventListener("click", function (e) {
791 var btn = e.target.closest("[data-subscrpt-detach]");
792 if (!btn) {
793 return;
794 }
795 if (!window.confirm(i18n.confirmDetach || "Detach this product from the plan?")) {
796 return;
797 }
798 var ids = (btn.getAttribute("data-relation-ids") || "")
799 .split(",")
800 .map(function (s) {
801 return s.trim();
802 })
803 .filter(Boolean);
804
805 btn.disabled = true;
806 Promise.all(
807 ids.map(function (id) {
808 return api("DELETE", "/relations/" + id);
809 }),
810 )
811 .then(function () {
812 refreshPlanView();
813 })
814 .catch(function (err) {
815 btn.disabled = false;
816 save.notify(err.message || i18n.connectError, "error");
817 });
818 });
819
820 /* ------------------------------------------------------------------ *
821 * Edit the connected plans of a group: add / remove individual plans
822 * (terms) and set each one's per-product price, inline in the card.
823 * ------------------------------------------------------------------ */
824
825 /**
826 * Toggle a plan card between read and edit mode.
827 *
828 * @param {HTMLElement} card The [data-subscrpt-plan-card] element.
829 * @param {boolean} editing Desired mode.
830 */
831 function cardMode(card, editing) {
832 // A simple product has one view/edit pair; a variable product has one edit
833 // table per variation (all toggled together), and no chip view.
834 card.querySelectorAll(".subscrpt-pe-view").forEach(function (view) {
835 view.style.display = editing ? "none" : "flex";
836 });
837 card.querySelectorAll(".subscrpt-pe-edit").forEach(function (edit) {
838 edit.style.display = editing ? "block" : "none";
839 });
840 card.querySelectorAll(".subscrpt-edit-only").forEach(function (el) {
841 el.style.display = editing ? "inline-flex" : "none";
842 });
843 var toggle = function (sel, show) {
844 var el = card.querySelector(sel);
845 if (el) {
846 el.style.display = show ? "" : "none";
847 }
848 };
849 toggle("[data-subscrpt-edit-prices]", !editing);
850 toggle("[data-subscrpt-detach]", !editing);
851 toggle("[data-subscrpt-cancel-prices]", editing);
852 toggle("[data-subscrpt-save-prices]", editing);
853 }
854
855 /** The product-level one-time purchase card (single, product-specific). */
856 function oneTimeCard() {
857 var wrap = root();
858 return wrap ? wrap.querySelector("[data-subscrpt-onetime-card]") : null;
859 }
860
861 /**
862 * Unlock (snapshot) or re-lock + revert the one-time card's inputs. Only acts
863 * when the card is edit-gated (connected + Pro), mirroring the plan card's
864 * edit mode; in the connect flow the inputs are already editable.
865 *
866 * While connected the whole card is hidden and only revealed here on "Edit
867 * prices", so the read view shows just the plan chips.
868 *
869 * @param {boolean} editing Desired mode.
870 */
871 function setOneTimeEditing(editing) {
872 var ot = oneTimeCard();
873 if (!ot) {
874 return;
875 }
876 // Reveal / hide the whole card with edit mode (Pro editable, non-Pro upsell).
877 var wrap = ot.closest("[data-subscrpt-onetime-wrap]");
878 if (wrap) {
879 wrap.style.display = editing ? "" : "none";
880 }
881 // Only the connected + Pro card unlocks its inputs; connect-flow inputs are
882 // already editable, non-Pro stays a locked upsell.
883 if (!ot.hasAttribute("data-subscrpt-ot-editgated")) {
884 return;
885 }
886 ot.querySelectorAll("[data-subscrpt-onetime-enable], [data-ot-field]").forEach(function (el) {
887 if (editing) {
888 el.dataset.orig = "checkbox" === el.type ? (el.checked ? "1" : "") : el.value;
889 el.disabled = false;
890 } else {
891 if ("checkbox" === el.type) {
892 el.checked = "1" === el.dataset.orig;
893 } else {
894 el.value = el.dataset.orig || "";
895 }
896 el.disabled = true;
897 }
898 });
899 }
900
901 /** Enabled/dim a term row's price inputs from its offer toggle. */
902 function syncTermRow(row) {
903 var on = row.querySelector("[data-subscrpt-term-toggle]");
904 var enabled = on ? on.checked : true;
905 row.querySelectorAll("[data-field]").forEach(function (input) {
906 input.disabled = !enabled;
907 input.style.opacity = enabled ? "" : "0.5";
908 });
909 }
910
911 // Enter edit mode (snapshot for cancel).
912 document.addEventListener("click", function (e) {
913 var btn = e.target.closest("[data-subscrpt-edit-prices]");
914 if (!btn) {
915 return;
916 }
917 var card = btn.closest("[data-subscrpt-plan-card]");
918 card.querySelectorAll("[data-subscrpt-term-row]").forEach(function (row) {
919 row.querySelectorAll("[data-field]").forEach(function (input) {
920 input.dataset.orig = input.value;
921 });
922 var on = row.querySelector("[data-subscrpt-term-toggle]");
923 if (on) {
924 on.dataset.orig = on.checked ? "1" : "";
925 }
926 syncTermRow(row);
927 });
928 var ven = card.querySelector("[data-subscrpt-var-enable]");
929 if (ven) {
930 ven.dataset.orig = ven.checked ? "1" : "";
931 ven.disabled = false;
932 }
933 setOneTimeEditing(true);
934 cardMode(card, true);
935 });
936
937 // A term's offer toggle enables its price inputs.
938 document.addEventListener("change", function (e) {
939 var toggle = e.target.closest("[data-subscrpt-term-toggle]");
940 if (!toggle) {
941 return;
942 }
943 var row = toggle.closest("[data-subscrpt-term-row]");
944 if (row) {
945 syncTermRow(row);
946 }
947 });
948
949 // The "Allow one-time purchase" toggle shows / hides its price inputs.
950 document.addEventListener("change", function (e) {
951 var toggle = e.target.closest("[data-subscrpt-onetime-enable]");
952 if (!toggle) {
953 return;
954 }
955 var card = toggle.closest("[data-subscrpt-onetime-card]");
956 var body = card && card.querySelector("[data-subscrpt-onetime-body]");
957 if (body) {
958 body.style.display = toggle.checked ? "" : "none";
959 }
960 });
961
962 // Cancel: restore snapshot and leave edit mode.
963 document.addEventListener("click", function (e) {
964 var btn = e.target.closest("[data-subscrpt-cancel-prices]");
965 if (!btn) {
966 return;
967 }
968 var card = btn.closest("[data-subscrpt-plan-card]");
969 card.querySelectorAll("[data-subscrpt-term-row]").forEach(function (row) {
970 row.querySelectorAll("[data-field]").forEach(function (input) {
971 input.value = input.dataset.orig || "";
972 });
973 var on = row.querySelector("[data-subscrpt-term-toggle]");
974 if (on) {
975 on.checked = "1" === on.dataset.orig;
976 }
977 });
978 var ven = card.querySelector("[data-subscrpt-var-enable]");
979 if (ven) {
980 ven.checked = "1" === ven.dataset.orig;
981 ven.disabled = true;
982 }
983 setOneTimeEditing(false);
984 cardMode(card, false);
985 });
986
987 /**
988 * Collect the REST calls that persist one plan card's edits (relations +
989 * one-time). Shared by the card's own Save button and the product
990 * Update/Publish flow so plan edits save through both.
991 *
992 * @param {HTMLElement} card The [data-subscrpt-plan-card] element.
993 * @return {Array<Promise>}
994 */
995 function collectCardCalls(card) {
996 var wrap = root();
997 var productId = wrap ? parseInt(wrap.getAttribute("data-product-id"), 10) : 0;
998
999 var fieldVal = function (row, field) {
1000 var el = row.querySelector('[data-field="' + field + '"]');
1001 return el ? el.value : "";
1002 };
1003
1004 var calls = [];
1005 card.querySelectorAll("[data-subscrpt-term-row]").forEach(function (row) {
1006 var planId = parseInt(row.getAttribute("data-plan-id"), 10);
1007 var vid = parseInt(row.getAttribute("data-vid"), 10) || 0;
1008 var relId = row.getAttribute("data-relation-id");
1009 var on = row.querySelector("[data-subscrpt-term-toggle]").checked;
1010
1011 // "Enable subscription" is per variation: read the owning variation
1012 // card's toggle so the relation save can flip _subscrpt_enabled for it.
1013 var vcard = row.closest("[data-subscrpt-variation-card]");
1014 var venEl = vcard ? vcard.querySelector("[data-subscrpt-var-enable]") : null;
1015 var enabled = venEl ? venEl.checked : true;
1016
1017 var data = {
1018 regular_price: fieldVal(row, "regular_price"),
1019 sale_price: fieldVal(row, "sale_price"),
1020 discount_value: 0,
1021 };
1022
1023 if (relId) {
1024 // Existing relation: enable/disable via exclude (never delete here, so
1025 // it stays consistent with the Plans page). Detach removes everything.
1026 calls.push(
1027 api("PUT", "/relations/" + relId, {
1028 exclude: !on,
1029 data: data,
1030 enabled: enabled,
1031 }),
1032 );
1033 } else if (on) {
1034 // Not connected yet and enabled: create the relation.
1035 calls.push(
1036 api("POST", "/relations", {
1037 plan_id: planId,
1038 oid: productId,
1039 vid: vid,
1040 type: 1,
1041 status: "active",
1042 exclude: false,
1043 data: data,
1044 enabled: enabled,
1045 }),
1046 );
1047 }
1048 });
1049
1050 // One-time purchase saves with the plan Save/Connect — no separate button.
1051 // It is a block below the price table (data-vid on the block): the simple
1052 // product's is vid 0 (saved with the product-level payload), and each
1053 // variable product variation's is its vid (collected into a variations map).
1054 var otRows = card.querySelectorAll("[data-subscrpt-onetime]");
1055 if (otRows.length) {
1056 var variations = {};
1057 var simpleOt = null;
1058 otRows.forEach(function (row) {
1059 var otVid = parseInt(row.getAttribute("data-vid"), 10) || 0;
1060 var en = row.querySelector("[data-subscrpt-onetime-enable]");
1061 var pr = row.querySelector('[data-ot-field="price"]');
1062 var of = row.querySelector('[data-ot-field="offer"]');
1063 var vals = {
1064 enabled: en ? en.checked : false,
1065 price: pr ? pr.value : "",
1066 offer: of ? of.value : "",
1067 };
1068 if (otVid) {
1069 variations[otVid] = vals;
1070 } else {
1071 simpleOt = vals;
1072 }
1073 });
1074 if (Object.keys(variations).length) {
1075 calls.push(api("PUT", "/product-onetime/" + productId, { variations: variations }));
1076 }
1077 if (simpleOt) {
1078 calls.push(api("PUT", "/product-onetime/" + productId, simpleOt));
1079 }
1080 }
1081
1082 return calls;
1083 }
1084
1085 // Save: persist this card's plan prices, then refresh the plan view in place
1086 // (no full page reload).
1087 document.addEventListener("click", function (e) {
1088 var btn = e.target.closest("[data-subscrpt-save-prices]");
1089 if (!btn) {
1090 return;
1091 }
1092 var card = btn.closest("[data-subscrpt-plan-card]");
1093
1094 btn.disabled = true;
1095 Promise.all(collectCardCalls(card))
1096 .then(function () {
1097 refreshPlanView();
1098 })
1099 .catch(function (err) {
1100 btn.disabled = false;
1101 save.notify(err.message || i18n.connectError, "error");
1102 });
1103 });
1104
1105 /**
1106 * When the plan view is the active view, copy each variation's plan-view
1107 * enable toggle into its classic `subscrpt_var[<vid>][enabled]` checkbox so the
1108 * product's native save persists the plan-mode enable. No-op when the classic
1109 * view is active (its own checkboxes are authoritative) or for simple products.
1110 *
1111 * @param {HTMLElement} wrap The [data-subscrpt-product-plans] wrapper.
1112 */
1113 function mirrorPlanEnables(wrap) {
1114 var planView = wrap.querySelector("[data-subscrpt-plan-view]");
1115 // Plan view hidden → classic (simple) mode is active; leave classic fields.
1116 if (!planView || "none" === planView.style.display) {
1117 return;
1118 }
1119 planView.querySelectorAll("[data-subscrpt-variation-card][data-variation-id]").forEach(function (card) {
1120 var vid = card.getAttribute("data-variation-id");
1121 var toggle = card.querySelector("[data-subscrpt-var-enable]");
1122 if (!vid || !toggle) {
1123 return;
1124 }
1125 var classicBox = document.querySelector('input[name="subscrpt_var[' + vid + '][enabled]"]');
1126 if (classicBox) {
1127 classicBox.checked = toggle.checked;
1128 }
1129 });
1130 }
1131
1132 /**
1133 * When the plan view is active, mirror each one-time-purchase row's price /
1134 * offer into WooCommerce's native price inputs (General tab `#_regular_price`
1135 * / `#_sale_price` for a simple product; `variable_regular_price[i]` /
1136 * `variable_sale_price[i]` for a variation). The one-time price IS the native
1137 * price, so without this the product's own Update re-saves the stale native
1138 * fields and reverts the freshly-saved one-time edit.
1139 *
1140 * Only a field the merchant actually changed speaks for the product. An
1141 * input still holding what the server rendered (`defaultValue`) says nothing
1142 * newer than the price on the General tab, and copying it anyway wiped the
1143 * price of a product that had none saved yet and reverted an edit on one that
1144 * did — including from the one-time rows of plans that were never picked,
1145 * which the connect card renders hidden, one per available plan.
1146 *
1147 * @param {HTMLElement} wrap The [data-subscrpt-product-plans] wrapper.
1148 */
1149 function mirrorOnetimePrices(wrap) {
1150 var planView = wrap.querySelector("[data-subscrpt-plan-view]");
1151 if (!planView || "none" === planView.style.display) {
1152 return;
1153 }
1154 var changed = function (el) {
1155 return el && el.value !== el.defaultValue;
1156 };
1157 planView.querySelectorAll("[data-subscrpt-onetime]").forEach(function (row) {
1158 var vid = parseInt(row.getAttribute("data-vid"), 10) || 0;
1159 var priceEl = row.querySelector('[data-ot-field="price"]');
1160 var offerEl = row.querySelector('[data-ot-field="offer"]');
1161 if (!priceEl) {
1162 return;
1163 }
1164 // A plan block that is not the picked one is not being edited.
1165 var block = row.closest("[data-connect-block]");
1166 if (block && "none" === block.style.display) {
1167 return;
1168 }
1169 if (!changed(priceEl) && !changed(offerEl)) {
1170 return;
1171 }
1172 var regular = priceEl.value;
1173 var sale = offerEl ? offerEl.value : "";
1174
1175 if (!vid) {
1176 // Simple product → General tab native price inputs.
1177 var r = document.getElementById("_regular_price");
1178 var s = document.getElementById("_sale_price");
1179 if (r && changed(priceEl)) {
1180 r.value = regular;
1181 }
1182 if (s && changed(offerEl)) {
1183 s.value = sale;
1184 }
1185 return;
1186 }
1187
1188 // Variation → find its row index among the variation price inputs (only
1189 // present when the Variations tab has been loaded; otherwise WooCommerce
1190 // posts no variation prices to overwrite, so skipping is safe).
1191 var postId = document.querySelector('input[name^="variable_post_id["][value="' + vid + '"]');
1192 var match = postId ? postId.getAttribute("name").match(/\[(\d+)\]/) : null;
1193 if (!match) {
1194 return;
1195 }
1196 var i = match[1];
1197 var vr = document.querySelector('input[name="variable_regular_price[' + i + ']"]');
1198 var vs = document.querySelector('input[name="variable_sale_price[' + i + ']"]');
1199 if (vr && changed(priceEl)) {
1200 vr.value = regular;
1201 }
1202 if (vs && changed(offerEl)) {
1203 vs.value = sale;
1204 }
1205 });
1206 }
1207
1208 /* ------------------------------------------------------------------ *
1209 * Save plan edits with the product's Update/Publish button too, so the
1210 * merchant does not have to use the plan card's own Save. Any card with an
1211 * active Save control (a connected card in edit mode, or a picked connect
1212 * group) is persisted via REST before the product form submits.
1213 * ------------------------------------------------------------------ */
1214 (function () {
1215 var postForm = document.getElementById("post");
1216 if (!postForm) {
1217 return;
1218 }
1219
1220 var lastSubmitBtn = null;
1221 var bypass = false;
1222
1223 // Remember which submit button was used so we can replay it after the async
1224 // plan save (a programmatic form.submit() would drop the button's name/value).
1225 document.addEventListener(
1226 "click",
1227 function (e) {
1228 var b = e.target.closest("#publish, #save-post");
1229 if (b) {
1230 lastSubmitBtn = b;
1231 }
1232 },
1233 true,
1234 );
1235
1236 postForm.addEventListener("submit", function (e) {
1237 if (bypass) {
1238 return;
1239 }
1240 var wrap = root();
1241 if (!wrap) {
1242 return;
1243 }
1244
1245 // Enable is mode-aware: when the plan view is the ACTIVE view, mirror each
1246 // variation's plan-view enable toggle into its classic
1247 // subscrpt_var[<vid>][enabled] checkbox, so the product's own save persists
1248 // the enable the admin set in plan view. When the classic (simple) view is
1249 // active we leave the classic checkboxes untouched — they win. Either way
1250 // per-variation enable is respected without the two sources clobbering.
1251 mirrorPlanEnables(wrap);
1252
1253 // One-time price is the native product price; mirror it into WooCommerce's
1254 // own price inputs so the product's Update doesn't revert the edit.
1255 mirrorOnetimePrices(wrap);
1256
1257 // Cards with a currently-visible Save control have pending plan edits.
1258 var cards = Array.prototype.filter.call(wrap.querySelectorAll("[data-subscrpt-plan-card]"), function (card) {
1259 var save = card.querySelector("[data-subscrpt-save-prices]");
1260 return save && null !== save.offsetParent;
1261 });
1262 if (!cards.length) {
1263 return;
1264 }
1265
1266 e.preventDefault();
1267
1268 var calls = [];
1269 cards.forEach(function (card) {
1270 calls = calls.concat(collectCardCalls(card));
1271 });
1272
1273 Promise.all(calls)
1274 .then(function () {
1275 bypass = true;
1276 if (lastSubmitBtn) {
1277 lastSubmitBtn.click();
1278 } else {
1279 postForm.submit();
1280 }
1281 })
1282 .catch(function (err) {
1283 save.notify(err.message || i18n.connectError, "error");
1284 });
1285 });
1286 })();
1287 })();
1288