PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.1.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.1.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 / onboarding-wizard.js

onboarding-wizard.js in Subscriptions for WooCommerce with Stripe Recurring Payments 2.1.0, at assets/js/admin/onboarding-wizard.js

920 lines 33.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Onboarding Wizard — SPA-style.
3 *
4 * Flow (nothing is created until the final step):
5 * 1. Create Plan — pick a plan type + name (name auto-fills from the type).
6 * 2. Durations — one or more billing durations.
7 * 3. Connect — choose a new or existing product to attach the plan to.
8 * 4. Finish — creates everything in order: plan group -> one term per
9 * duration -> product (if new) -> a relation per term.
10 *
11 * Steps 1–3 only collect and validate input; the plan (group), durations
12 * (terms) and product relations are all created together on step 4 through the
13 * Plans REST API (wpsubscription/v1/plans). Creating a brand-new product uses
14 * an admin-ajax handler. All PHP values arrive via `subscrpt_wizard`.
15 */
16 (function ($) {
17 "use strict";
18
19 var INTERVAL_TO_INT = { day: 1, week: 2, month: 3, year: 4 };
20
21 // The preview graph has 3 duration slots, so durations are capped there.
22 var MAX_DURATIONS = 3;
23
24 var Wizard = {
25 cfg: {},
26 MAX_DURATIONS: MAX_DURATIONS,
27 autoName: "",
28 // Everything below is created on the final step (page 4), not before.
29 groupId: 0,
30 termIds: [],
31 planTitle: "",
32 billingText: "",
33 finalProductId: "",
34 finalProductName: "",
35 relationsCreated: false,
36 finalizeRunning: false,
37 // Durations show as placeholder ghost cards in the preview until the user
38 // reaches page 2 and starts editing them.
39 reachedDurations: false,
40
41 init: function () {
42 this.cfg = window.subscrpt_wizard || {};
43 this.hasProducts = $("#subscrpt-has-products").val() === "1";
44 this.autoName = $.trim($("#subscrpt_plan_title").val());
45 this.bindEvents();
46 this.initLivePreview();
47 this.initFocusZoom();
48 this.initDurations();
49 $("#subscrpt-link-plans").attr("href", this.cfg.plans_url || "#");
50 $("#subscrpt-link-products").attr("href", this.cfg.products_url || "#");
51 // Start on the welcome page (normalises stepper, footer nav and preview).
52 this.switchSection(0);
53 },
54
55 // ----- REST helper -----
56
57 api: function (method, path, body) {
58 return fetch(this.cfg.rest_url + path, {
59 method: method,
60 credentials: "same-origin",
61 headers: {
62 "Content-Type": "application/json",
63 "X-WP-Nonce": this.cfg.rest_nonce || "",
64 },
65 body: body ? JSON.stringify(body) : undefined,
66 }).then(function (res) {
67 return res.json().then(function (data) {
68 if (!res.ok) {
69 throw new Error((data && data.message) || "Request failed.");
70 }
71 return data;
72 });
73 });
74 },
75
76 // ----- Events -----
77
78 bindEvents: function () {
79 // Page 0 (welcome).
80 $(document).on("click", "#subscrpt-btn-start", $.proxy(this.goToPage, this, 1));
81 $(document).on("click", "#subscrpt-btn-skip", $.proxy(this.skip, this));
82
83 // Page 1 (plan).
84 $(document).on("click", ".wpsubs-plan-type-card", $.proxy(this.selectPlanType, this));
85 $(document).on("click", "#subscrpt-btn-back-0", $.proxy(this.goToPage, this, 0));
86 $(document).on("click", "#subscrpt-btn-next-1", $.proxy(this.nextFromPlan, this));
87
88 // Page 2 (durations).
89 $(document).on("click", "#subscrpt-btn-back-1", $.proxy(this.goToPage, this, 1));
90 $(document).on("click", "#subscrpt-btn-next-2", $.proxy(this.nextFromDurations, this));
91 $(document).on("click", "#subscrpt-btn-add-duration", $.proxy(this.addDuration, this));
92 $(document).on("click", "[data-dur-toggle]", $.proxy(this.onDurToggle, this));
93 $(document).on("click", "[data-dur-remove]", $.proxy(this.onDurRemove, this));
94 $(document).on("input", "[data-dur-freq]", $.proxy(this.onDurBillingInput, this));
95 $(document).on("wpsubs:select", "[data-dur-interval]", $.proxy(this.onDurBillingInput, this));
96 $(document).on("input", "[data-dur-name]", $.proxy(this.onDurNameInput, this));
97
98 // Page 3 (product).
99 $(document).on("click", "#subscrpt-btn-back-2", $.proxy(this.goToPage, this, 2));
100 $(document).on("click", ".wpsubs-connect-mode-card", $.proxy(this.selectConnectMode, this));
101 $(document).on("change", "[data-connect-enabled]", $.proxy(this.onConnectToggle, this));
102 $(document).on("input", "[data-connect-price]", $.proxy(this.updatePreview, this));
103 $(document).on("click", "#subscrpt-btn-next-3", $.proxy(this.nextFromProduct, this));
104 $(document).on("focus", "#subscrpt-product-search-input", this.openProductSearch);
105 $(document).on("input", "#subscrpt-product-search-input", this.filterProducts);
106 $(document).on("click", ".wpsubs-p2-product-search__item", $.proxy(this.onProductPick, this));
107 $(document).on("click", "#subscrpt-btn-clear-product", $.proxy(this.clearProduct, this));
108 $(document).on("click", function (e) {
109 if (!$(e.target).closest(".wpsubs-p2-product-search").length) {
110 $("#subscrpt-product-search-dropdown").hide();
111 }
112 });
113
114 // Page 4 (finish).
115 $(document).on("click", "#subscrpt-btn-retry-finalize", $.proxy(this.finalize, this));
116 $(document).on("click", "#subscrpt-btn-add-another", $.proxy(this.restart, this));
117 },
118
119 // ----- Navigation -----
120
121 goToPage: function (pageNum, e) {
122 if (e && e.preventDefault) {
123 e.preventDefault();
124 }
125 this.switchSection(pageNum);
126 },
127
128 switchSection: function (pageNum) {
129 $("#subscrpt-wizard-page").val(pageNum);
130
131 // Once the user lands on the durations step, the preview duration nodes
132 // stop being placeholders and reflect the real durations.
133 if (pageNum >= 2) {
134 this.reachedDurations = true;
135 }
136
137 // The welcome page (0) has no stepper, no live preview and no footer nav —
138 // it's a standalone intro with its own button.
139 $("#subscrpt-stepper").toggle(pageNum !== 0);
140 $(".wpsubs-wizard-layout").toggleClass("is-welcome", pageNum === 0);
141
142 $(".wpsubs-wizard-stepper__step").removeClass("active done");
143 $(".wpsubs-wizard-stepper__step").each(function () {
144 var step = parseInt($(this).data("step"), 10);
145 if (step < pageNum) {
146 $(this).addClass("done");
147 } else if (step === pageNum) {
148 $(this).addClass("active");
149 }
150 });
151
152 $(".wpsubs-wizard-section").removeClass("active");
153 $("#subscrpt-section-" + pageNum).addClass("active");
154
155 // Build the per-duration pricing rows from the durations set on step 2.
156 if (pageNum === 3) {
157 this.renderConnectDurations();
158 }
159
160 // Swap the footer nav to this step's buttons.
161 $(".wpsubs-wizard-nav").attr("hidden", "hidden");
162 $('.wpsubs-wizard-nav[data-nav="' + pageNum + '"]').removeAttr("hidden");
163
164 // Light up the part of the preview this step fills in.
165 var groups = { 0: "plan", 1: "plan", 2: "dur", 3: "prod", 4: "done" };
166 $("#subscrpt-preview-graph").attr("data-active", groups[pageNum] || "plan");
167 this.updatePreview();
168
169 $("html, body").animate({ scrollTop: 0 }, 150);
170 },
171
172 skip: function (e) {
173 e.preventDefault();
174 window.location.href = this.cfg.subscriptions_url;
175 },
176
177 // ----- Page 1: plan type + name -----
178
179 selectPlanType: function (e) {
180 var card = $(e.currentTarget);
181 $(".wpsubs-plan-type-card").removeClass("active");
182 card.addClass("active");
183 $("#subscrpt-plan-type").val(card.data("type"));
184
185 // Auto-fill the name, but never clobber a name the user has edited.
186 var suggested = card.data("name") ? String(card.data("name")) : "";
187 var current = $.trim($("#subscrpt_plan_title").val());
188 if (!current || current === this.autoName) {
189 $("#subscrpt_plan_title").val(suggested);
190 }
191 this.autoName = suggested;
192
193 this.updatePreview();
194 },
195
196 nextFromPlan: function (e) {
197 e.preventDefault();
198 if (!$.trim($("#subscrpt_plan_title").val())) {
199 window.alert("Please enter a plan name.");
200 return;
201 }
202 this.switchSection(2);
203 },
204
205 // ----- Page 2: live preview + create plan -----
206
207 initLivePreview: function () {
208 var self = this;
209 var update = function () {
210 self.updatePreview();
211 };
212 // Fields whose typing should reflect into the preview graph. Duration
213 // fields update the preview through their own handlers.
214 $(document).on("input", "#subscrpt_plan_title, #subscrpt_new_product_name", update);
215 },
216
217 // Zoom the matching preview card while its field is focused.
218 initFocusZoom: function () {
219 var graph = $("#subscrpt-preview-graph");
220 var groups = [
221 { sel: "#subscrpt_plan_title", group: "plan" },
222 {
223 sel: "#subscrpt-product-search-input, #subscrpt_new_product_name, #subscrpt-connect-durations [data-connect-price]",
224 group: "prod",
225 },
226 ];
227 groups.forEach(function (g) {
228 $(document).on("focusin", g.sel, function () {
229 graph.attr("data-focus", g.group);
230 });
231 $(document).on("focusout", g.sel, function () {
232 graph.attr("data-focus", "");
233 });
234 });
235
236 // Durations zoom per card: focusing one duration's field zooms only the
237 // preview node that duration maps to (fill order = card order).
238 var durFields = "#subscrpt-durations input, #subscrpt-durations .wpsubs-adv-select__trigger";
239 $(document).on("focusin", durFields, function () {
240 var idx = $("#subscrpt-durations [data-dur]").index($(this).closest("[data-dur]"));
241 $("#subscrpt-preview-graph [data-preview-dur]").removeClass("is-zoom");
242 $('#subscrpt-preview-graph [data-preview-dur="' + idx + '"]').addClass("is-zoom");
243 });
244 $(document).on("focusout", durFields, function () {
245 $("#subscrpt-preview-graph [data-preview-dur]").removeClass("is-zoom");
246 });
247 },
248
249 // The name shown on the preview's product node, from whichever connect mode
250 // is active.
251 previewProductName: function () {
252 if (this.hasProducts && this.currentConnectMode() === "existing") {
253 return this.selectedProductName || "";
254 }
255 return $.trim($("#subscrpt_new_product_name").val());
256 },
257
258 // Show the price range (min–max of the toggled-on durations' prices) on the
259 // product node's subtitle; falls back to "Subscribable" when no price set.
260 updateProductPriceSub: function () {
261 var sym = this.cfg.currency_symbol || "$";
262 var prices = [];
263 $("#subscrpt-connect-durations [data-connect-row]").each(function () {
264 if (!$(this).find("[data-connect-enabled]").is(":checked")) {
265 return;
266 }
267 var raw = $.trim($(this).find("[data-connect-price]").val());
268 if (raw === "") {
269 return;
270 }
271 var n = parseFloat(raw);
272 if (!isNaN(n) && n >= 0) {
273 prices.push(n);
274 }
275 });
276
277 var fmt = function (v) {
278 return sym + v.toFixed(2);
279 };
280 var text = "Price";
281 if (prices.length) {
282 var min = Math.min.apply(null, prices);
283 var max = Math.max.apply(null, prices);
284 text = min === max ? fmt(min) : fmt(min) + " - " + fmt(max);
285 }
286 $("#subscrpt-preview-prod-sub").text(text);
287 },
288
289 // Fill the persistent preview graph from the current form state. Each step
290 // updates its own node; empty fields keep the placeholder label.
291 updatePreview: function () {
292 $("#subscrpt-preview-plan").text($.trim($("#subscrpt_plan_title").val()) || "Your plan");
293 $("#subscrpt-preview-plan-type").text($(".wpsubs-plan-type-card.active").data("label") || "Recurring");
294
295 // Duration nodes: stay placeholder ghost cards until the user reaches
296 // page 2, then each real duration lights up one node in fill order.
297 var self = this;
298 var durations = this.reachedDurations ? this.collectDurations() : [];
299 $("#subscrpt-preview-graph [data-preview-dur]").each(function () {
300 var $node = $(this);
301 var idx = parseInt($node.data("preview-dur"), 10) || 0;
302 var dur = durations[idx];
303 if (dur) {
304 $node.removeClass("wpsubs-p1-node--ghost");
305 $node.find(".wpsubs-p1-node__title").text(dur.name);
306 $node.find(".wpsubs-p1-node__sub").text(self.billingEvery(dur.freq, dur.interval));
307 } else {
308 $node.addClass("wpsubs-p1-node--ghost");
309 $node.find(".wpsubs-p1-node__title").text("Duration");
310 $node.find(".wpsubs-p1-node__sub").text("Add more");
311 }
312 });
313
314 var prod = this.previewProductName();
315 if (prod) {
316 $("#subscrpt-preview-prod").text(prod);
317 }
318 // The product node's subtitle shows the price range from the set pricing.
319 this.updateProductPriceSub();
320
321 // Which durations are toggled on for the product (page 3). If the rows
322 // aren't rendered yet, treat every duration as on.
323 var enabled = {};
324 $("#subscrpt-connect-durations [data-connect-row]").each(function () {
325 enabled[parseInt($(this).data("connect-dur"), 10)] = $(this).find("[data-connect-enabled]").is(":checked");
326 });
327
328 // Connector lines: muted by default. A plan->duration line colours in
329 // once its duration is filled; a duration->product line also needs a
330 // product to be added and that duration's toggle to be on.
331 var hasProduct = !!prod;
332 $("#subscrpt-preview-graph [data-line-dur]").each(function () {
333 var $line = $(this);
334 var idx = parseInt($line.data("line-dur"), 10) || 0;
335 var active = !!durations[idx];
336 if ($line.attr("data-line-to") === "prod") {
337 active = active && hasProduct && enabled[idx] !== false;
338 }
339 $line.toggleClass("is-active", active);
340 });
341 },
342
343 // ----- Durations (accordion) -----
344
345 // The "billing every" value: "1 month", "3 days".
346 billingEvery: function (freq, interval) {
347 var n = parseInt(freq, 10) || 1;
348 return n + " " + (interval || "month") + (n > 1 ? "s" : "");
349 },
350
351 // "1 month" -> "Every Month", "3 days" -> "Every 3 Days".
352 durationName: function (freq, interval) {
353 var labels = { day: "Day", week: "Week", month: "Month", year: "Year" };
354 var label = labels[interval] || "Month";
355 var n = parseInt(freq, 10) || 1;
356 return n > 1 ? "Every " + n + " " + label + "s" : "Every " + label;
357 },
358
359 initDurations: function () {
360 if (!$("#subscrpt-durations [data-dur]").length) {
361 this.addDuration();
362 }
363 this.refreshDurControls();
364 },
365
366 addDuration: function (e) {
367 if (e && e.preventDefault) {
368 e.preventDefault();
369 }
370 if ($("#subscrpt-durations [data-dur]").length >= this.MAX_DURATIONS) {
371 return;
372 }
373 var tpl = document.getElementById("subscrpt-duration-tpl");
374 if (!tpl || !tpl.content) {
375 return;
376 }
377 $("#subscrpt-durations").append(tpl.content.cloneNode(true));
378 var card = $("#subscrpt-durations [data-dur]").last();
379 // Wire up the cloned cadence picker (adv-select).
380 if (window.WPSubsAdvSelect) {
381 window.WPSubsAdvSelect.init(card[0]);
382 }
383 this.syncDurName(card, true);
384 this.openDuration(card);
385 this.refreshDurControls();
386 this.updatePreview();
387 },
388
389 openDuration: function (card) {
390 $("#subscrpt-durations [data-dur]").removeClass("is-open");
391 card.addClass("is-open");
392 },
393
394 onDurToggle: function (e) {
395 if ($(e.target).closest("[data-dur-remove]").length) {
396 return;
397 }
398 var card = $(e.currentTarget).closest("[data-dur]");
399 if (card.hasClass("is-open")) {
400 card.removeClass("is-open");
401 } else {
402 this.openDuration(card);
403 }
404 },
405
406 onDurRemove: function (e) {
407 e.preventDefault();
408 e.stopPropagation();
409 var cards = $("#subscrpt-durations [data-dur]");
410 if (cards.length <= 1) {
411 return;
412 }
413 var card = $(e.currentTarget).closest("[data-dur]");
414 var wasOpen = card.hasClass("is-open");
415 card.remove();
416 if (wasOpen) {
417 $("#subscrpt-durations [data-dur]").last().addClass("is-open");
418 }
419 this.refreshDurControls();
420 this.updatePreview();
421 },
422
423 onDurBillingInput: function (e) {
424 this.syncDurName($(e.target).closest("[data-dur]"), false);
425 this.updatePreview();
426 },
427
428 onDurNameInput: function (e) {
429 var card = $(e.target).closest("[data-dur]");
430 card.attr("data-name-edited", "1");
431 card.find("[data-dur-title]").text($.trim(card.find("[data-dur-name]").val()) || "Duration");
432 this.updatePreview();
433 },
434
435 // The interval value ("day"/"week"/"month"/"year") from a card's cadence
436 // picker — the adv-select's hidden input.
437 durInterval: function (card) {
438 return card.find("[data-dur-interval] input[type=hidden]").val() || "month";
439 },
440
441 // Refresh the auto name from the billing period (unless the user edited it)
442 // and mirror the name into the card header.
443 syncDurName: function (card, force) {
444 var auto = this.durationName(card.find("[data-dur-freq]").val(), this.durInterval(card));
445 if (force || card.attr("data-name-edited") !== "1") {
446 card.find("[data-dur-name]").val(auto);
447 }
448 card.find("[data-dur-title]").text($.trim(card.find("[data-dur-name]").val()) || auto);
449 },
450
451 refreshDurControls: function () {
452 var count = $("#subscrpt-durations [data-dur]").length;
453 $("#subscrpt-durations").toggleClass("has-multiple", count > 1);
454 // The preview has 3 duration slots — no more durations past that.
455 $("#subscrpt-btn-add-duration").toggle(count < this.MAX_DURATIONS);
456 },
457
458 collectDurations: function () {
459 var self = this;
460 var out = [];
461 $("#subscrpt-durations [data-dur]").each(function () {
462 var $c = $(this);
463 var freq = parseInt($c.find("[data-dur-freq]").val(), 10) || 1;
464 var interval = self.durInterval($c);
465 var name = $.trim($c.find("[data-dur-name]").val()) || self.durationName(freq, interval);
466 out.push({ freq: freq, interval: interval, name: name });
467 });
468 return out;
469 },
470
471 // Nothing is created here — just validate and move on. The plan, durations
472 // and product are all created together on the final step.
473 nextFromDurations: function (e) {
474 e.preventDefault();
475 if (!$.trim($("#subscrpt_plan_title").val())) {
476 window.alert("Please enter a plan name.");
477 this.switchSection(1);
478 return;
479 }
480 if (!this.collectDurations().length) {
481 window.alert("Please add at least one duration.");
482 return;
483 }
484 this.switchSection(3);
485 },
486
487 // ----- Page 3: connect to a product -----
488
489 // Which connect mode is active. Without existing products the only option
490 // is creating a new one.
491 currentConnectMode: function () {
492 if (!this.hasProducts) {
493 return "new";
494 }
495 var active = $(".wpsubs-connect-mode-card.active");
496 return active.length ? active.data("mode") : "existing";
497 },
498
499 selectConnectMode: function (e) {
500 var card = $(e.currentTarget);
501 var mode = card.data("mode");
502 $(".wpsubs-connect-mode-card").removeClass("active");
503 card.addClass("active");
504 $("#subscrpt-connect-existing").toggle(mode === "existing");
505 $("#subscrpt-connect-new").toggle(mode === "new");
506 this.updatePreview();
507 },
508
509 openProductSearch: function () {
510 $(".wpsubs-p2-product-search__item").show();
511 $(".wpsubs-p2-product-search__empty").hide();
512 $("#subscrpt-product-search-dropdown").show();
513 },
514
515 filterProducts: function (e) {
516 var q = $(e.target).val().toLowerCase().trim();
517 $("#subscrpt-product-search-dropdown").show();
518 var visible = 0;
519 $(".wpsubs-p2-product-search__item").each(function () {
520 var name = $(this).data("name") ? String($(this).data("name")).toLowerCase() : "";
521 var sku = $(this).data("sku") ? String($(this).data("sku")).toLowerCase() : "";
522 if (!q || name.indexOf(q) >= 0 || sku.indexOf(q) >= 0) {
523 $(this).show();
524 visible++;
525 } else {
526 $(this).hide();
527 }
528 });
529 $(".wpsubs-p2-product-search__empty").toggle(visible === 0);
530 },
531
532 onProductPick: function (e) {
533 var item = $(e.currentTarget);
534 // Products already attached to a plan can't be picked.
535 if (item.attr("data-connected") === "1") {
536 return;
537 }
538 var id = String(item.data("id"));
539 var name = item.data("name") || "";
540 var price = item.data("price") != null ? String(item.data("price")) : "";
541 var type = item.data("type") || "";
542 var sku = item.data("sku") ? String(item.data("sku")) : "";
543
544 $("#subscrpt-existing-product-hidden").val(id);
545 $("#subscrpt-product-search-dropdown").hide();
546
547 var initials = name
548 .split(" ")
549 .filter(Boolean)
550 .slice(0, 2)
551 .map(function (w) {
552 return w[0].toUpperCase();
553 })
554 .join("");
555 var meta = [sku ? "SKU " + sku : null, type].filter(Boolean).join(" · ");
556
557 $("#p3-chip-avatar").text(initials || "?");
558 $("#p3-chip-name").text(name);
559 $("#p3-chip-meta").text(meta);
560 $("#subscrpt-product-select-wrap").hide();
561 $("#subscrpt-selected-product-chip").show();
562
563 // Prefill any empty duration price with the product's price as a starting
564 // point; leave prices the user already typed alone.
565 if (price) {
566 $("#subscrpt-connect-durations [data-connect-price]").each(function () {
567 if (!$.trim($(this).val())) {
568 $(this).val(parseFloat(price).toFixed(2));
569 }
570 });
571 }
572
573 this.selectedProductName = name;
574 this.updatePreview();
575 },
576
577 clearProduct: function () {
578 $("#subscrpt-existing-product-hidden").val("");
579 $("#subscrpt-product-search-input").val("");
580 $(".wpsubs-p2-product-search__item").show();
581 $("#subscrpt-selected-product-chip").hide();
582 $("#subscrpt-product-select-wrap").show();
583 this.selectedProductName = "";
584 },
585
586 // Snapshot the current price/toggle of each duration row, keyed by index,
587 // so re-rendering doesn't lose what the user typed.
588 readConnectState: function () {
589 var state = {};
590 $("#subscrpt-connect-durations [data-connect-row]").each(function () {
591 var idx = parseInt($(this).data("connect-dur"), 10);
592 state[idx] = {
593 price: $(this).find("[data-connect-price]").val(),
594 enabled: $(this).find("[data-connect-enabled]").is(":checked"),
595 };
596 });
597 return state;
598 },
599
600 // Build one pricing row per duration (from step 2), preserving any values
601 // already entered. All rows connect to the single product.
602 renderConnectDurations: function () {
603 var self = this;
604 var tpl = document.getElementById("subscrpt-connect-dur-tpl");
605 if (!tpl || !tpl.content) {
606 return;
607 }
608 var prev = this.readConnectState();
609 var durations = this.collectDurations();
610 var $wrap = $("#subscrpt-connect-durations").empty();
611
612 durations.forEach(function (dur, idx) {
613 var $frag = $(tpl.content.cloneNode(true));
614 var $row = $frag.find("[data-connect-row]");
615 $row.attr("data-connect-dur", idx);
616 $row.find("[data-connect-name]").text(dur.name);
617 $row.find("[data-connect-billing]").text("Billing every " + self.billingEvery(dur.freq, dur.interval));
618 if (prev[idx]) {
619 $row.find("[data-connect-price]").val(prev[idx].price);
620 $row.find("[data-connect-enabled]").prop("checked", prev[idx].enabled);
621 }
622 $row.toggleClass("is-off", !$row.find("[data-connect-enabled]").is(":checked"));
623 $wrap.append($frag);
624 });
625 },
626
627 onConnectToggle: function (e) {
628 var $row = $(e.target).closest("[data-connect-row]");
629 $row.toggleClass("is-off", !$(e.target).is(":checked"));
630 this.updatePreview();
631 },
632
633 // Nothing is created here either — validate the product choice and the
634 // per-duration prices, then move to the final step which does all the work.
635 nextFromProduct: function (e) {
636 e.preventDefault();
637
638 if (this.currentConnectMode() === "existing") {
639 if (!$("#subscrpt-existing-product-hidden").val()) {
640 window.alert("Please select a product.");
641 return;
642 }
643 } else if (!$.trim($("#subscrpt_new_product_name").val())) {
644 window.alert("Please enter a product name.");
645 return;
646 }
647
648 var rows = $("#subscrpt-connect-durations [data-connect-row]");
649 var enabledRows = rows.filter(function () {
650 return $(this).find("[data-connect-enabled]").is(":checked");
651 });
652 if (!enabledRows.length) {
653 window.alert("Please keep at least one duration on.");
654 return;
655 }
656 var badPrice = false;
657 enabledRows.each(function () {
658 var p = $.trim($(this).find("[data-connect-price]").val());
659 if (p && (isNaN(parseFloat(p)) || parseFloat(p) < 0)) {
660 badPrice = true;
661 }
662 });
663 if (badPrice) {
664 window.alert("Please enter valid prices.");
665 return;
666 }
667
668 this.switchSection(4);
669 this.finalize();
670 },
671
672 // ----- Page 4: create everything sequentially -----
673
674 // Mark a progress step's state ("is-doing" / "is-done").
675 finalizeStep: function (key, state) {
676 $('[data-finalize-step="' + key + '"]')
677 .removeClass("is-doing is-done")
678 .addClass(state);
679 },
680
681 // Run the whole creation flow in order: plan -> durations -> product +
682 // relations. Each sub-step is skipped if a retry already completed it.
683 finalize: function (e) {
684 if (e && e.preventDefault) {
685 e.preventDefault();
686 }
687 if (this.finalizeRunning) {
688 return;
689 }
690 this.finalizeRunning = true;
691
692 var self = this;
693 $("#subscrpt-finalize-error, #subscrpt-finalize-success").attr("hidden", "hidden");
694 $("#subscrpt-finalize-progress").removeAttr("hidden");
695 $("#subscrpt-link-plans, #subscrpt-link-products").attr("hidden", "hidden");
696 $("[data-finalize-step]").removeClass("is-doing is-done");
697
698 this.ensurePlan()
699 .then(function () {
700 return self.ensureProductAndConnect();
701 })
702 .then(function () {
703 self.finalizeRunning = false;
704 self.showDone(self.finalProductName);
705 })
706 .catch(function (err) {
707 self.finalizeRunning = false;
708 $("#subscrpt-finalize-progress").attr("hidden", "hidden");
709 $("#subscrpt-finalize-error-msg").text(err && err.message ? err.message : "Please try again.");
710 $("#subscrpt-finalize-error").removeAttr("hidden");
711 });
712 },
713
714 termBody: function (dur, groupId, type) {
715 return {
716 plan_group_id: groupId,
717 type: type,
718 title: dur.name,
719 billing_frequency: dur.freq,
720 billing_interval: INTERVAL_TO_INT[dur.interval] || 3,
721 billing_length: 0,
722 free_trial: "",
723 signup_fee: { amount: "" },
724 status: "active",
725 data: { free_trial_interval: "day" },
726 };
727 },
728
729 // Create the plan group + one term per duration (reusing the auto-seeded
730 // draft term for the first). No-op if a retry already created them.
731 ensurePlan: function () {
732 var self = this;
733 if (this.groupId && this.termIds && this.termIds.length) {
734 this.finalizeStep("plan", "is-done");
735 this.finalizeStep("durations", "is-done");
736 return Promise.resolve();
737 }
738
739 var title = $.trim($("#subscrpt_plan_title").val());
740 var type = $("#subscrpt-plan-type").val() || "recurring";
741 var durations = this.collectDurations();
742
743 this.finalizeStep("plan", "is-doing");
744 return this.api("POST", "/groups", { title: title, type: type, product_type: 1, status: "active" }).then(
745 function (group) {
746 self.groupId = group.id;
747 self.planTitle = title;
748 self.billingText = durations[0].name;
749 self.finalizeStep("plan", "is-done");
750 self.finalizeStep("durations", "is-doing");
751
752 var seeded = group.plans && group.plans.length ? group.plans[0] : null;
753 var termIds = [];
754 var chain = durations.reduce(function (promise, dur, idx) {
755 return promise.then(function () {
756 var body = self.termBody(dur, group.id, type);
757 if (idx === 0 && seeded && seeded.id) {
758 return self.api("PUT", "/terms/" + seeded.id, body).then(function () {
759 termIds.push(seeded.id);
760 });
761 }
762 return self.api("POST", "/terms", body).then(function (term) {
763 termIds.push(term.id);
764 });
765 });
766 }, Promise.resolve());
767
768 return chain.then(function () {
769 self.termIds = termIds;
770 self.finalizeStep("durations", "is-done");
771 });
772 },
773 );
774 },
775
776 // Create the product (if new) and connect the plan to it. No-op if a retry
777 // already connected it.
778 ensureProductAndConnect: function () {
779 var self = this;
780 if (this.relationsCreated) {
781 this.finalizeStep("product", "is-done");
782 return Promise.resolve();
783 }
784 this.finalizeStep("product", "is-doing");
785 return this.ensureProduct()
786 .then(function () {
787 return self.createRelations();
788 })
789 .then(function () {
790 self.relationsCreated = true;
791 self.finalizeStep("product", "is-done");
792 });
793 },
794
795 // Resolve the product id — an existing selection, or a newly created one
796 // (name only; each duration carries its own price on the relation).
797 ensureProduct: function () {
798 var self = this;
799 if (this.finalProductId) {
800 return Promise.resolve();
801 }
802
803 if (this.currentConnectMode() === "existing") {
804 this.finalProductId = $("#subscrpt-existing-product-hidden").val();
805 this.finalProductName = this.selectedProductName || "Product";
806 return Promise.resolve();
807 }
808
809 var name = $.trim($("#subscrpt_new_product_name").val());
810 this.finalProductName = name;
811 return new Promise(function (resolve, reject) {
812 $.post(
813 self.cfg.ajax_url,
814 {
815 action: "subscrpt_create_wizard_product",
816 nonce: $("#subscrpt_wizard_nonce").val(),
817 product_name: name,
818 },
819 function (response) {
820 if (response && response.success) {
821 self.finalProductId = response.data.product_id;
822 resolve();
823 } else {
824 reject(
825 new Error((response && response.data && response.data.message) || "Could not create the product."),
826 );
827 }
828 },
829 ).fail(function () {
830 reject(new Error("Server error. Please try again."));
831 });
832 });
833 },
834
835 // Connect the product to each toggled-on duration, using that duration's
836 // own price. Durations toggled off get no relation.
837 createRelations: function () {
838 var self = this;
839 var rows = $("#subscrpt-connect-durations [data-connect-row]");
840 var calls = [];
841 (this.termIds || []).forEach(function (tid, idx) {
842 var $row = rows.filter('[data-connect-dur="' + idx + '"]');
843 var enabled = $row.length ? $row.find("[data-connect-enabled]").is(":checked") : true;
844 if (!enabled) {
845 return;
846 }
847 var price = $row.length ? $.trim($row.find("[data-connect-price]").val()) : "";
848 calls.push(
849 self.api("POST", "/relations", {
850 plan_id: tid,
851 oid: parseInt(self.finalProductId, 10),
852 vid: 0,
853 type: 1,
854 status: "active",
855 exclude: false,
856 data: { regular_price: price, sale_price: "", discount_value: 0 },
857 }),
858 );
859 });
860 return Promise.all(calls);
861 },
862
863 showDone: function (productName) {
864 if (productName) {
865 $("#subscrpt-preview-prod").text(productName);
866 }
867 $("#subscrpt-finalize-progress, #subscrpt-finalize-error").attr("hidden", "hidden");
868 $("#subscrpt-finalize-success").removeAttr("hidden");
869 $("#subscrpt-link-plans, #subscrpt-link-products, #subscrpt-btn-finish").removeAttr("hidden");
870 $("#subscrpt-preview-graph").attr("data-active", "done");
871 this.updatePreview();
872 },
873
874 restart: function (e) {
875 e.preventDefault();
876 var self = this;
877 $.post(
878 this.cfg.ajax_url,
879 {
880 action: "subscrpt_reset_wizard",
881 nonce: $("#subscrpt_wizard_nonce").val(),
882 },
883 function () {
884 self.groupId = 0;
885 self.termIds = [];
886 self.planTitle = "";
887 self.billingText = "";
888 self.finalProductId = "";
889 self.finalProductName = "";
890 self.relationsCreated = false;
891 self.finalizeRunning = false;
892 // Reset the final step back to its progress state for a fresh run.
893 $("#subscrpt-finalize-success, #subscrpt-finalize-error").attr("hidden", "hidden");
894 $("#subscrpt-finalize-progress").removeAttr("hidden");
895 $("[data-finalize-step]").removeClass("is-doing is-done");
896 $("#subscrpt-link-plans, #subscrpt-link-products, #subscrpt-btn-finish").attr("hidden", "hidden");
897 $("#subscrpt_new_product_name").val("");
898 $("#subscrpt-connect-durations").empty();
899 $("#subscrpt-durations").empty();
900 self.initDurations();
901 self.clearProduct();
902 // Reset the preview nodes back to their placeholders.
903 $("#subscrpt-preview-prod").text("Product");
904 self.reachedDurations = false;
905 $("#subscrpt-preview-graph [data-preview-dur]")
906 .addClass("wpsubs-p1-node--ghost")
907 .find(".wpsubs-p1-node__title")
908 .text("Duration");
909 $("#subscrpt-preview-graph [data-preview-dur] .wpsubs-p1-node__sub").text("Add more");
910 self.switchSection(1);
911 },
912 );
913 },
914 };
915
916 $(document).ready(function () {
917 Wizard.init();
918 });
919 })(jQuery);
920