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

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

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