PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.4.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.4.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.4.0, at assets/js/admin/onboarding-wizard.js

968 lines 36.0 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) || wp.i18n.__("Request failed.", "subscription"));
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(wp.i18n.__("Please enter a plan name.", "subscription"));
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 = wp.i18n.__("Price", "subscription");
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(
293 $.trim($("#subscrpt_plan_title").val()) || wp.i18n.__("Your plan", "subscription"),
294 );
295 $("#subscrpt-preview-plan-type").text(
296 $(".wpsubs-plan-type-card.active").data("label") || wp.i18n.__("Recurring", "subscription"),
297 );
298
299 // Duration nodes: stay placeholder ghost cards until the user reaches
300 // page 2, then each real duration lights up one node in fill order.
301 var self = this;
302 var durations = this.reachedDurations ? this.collectDurations() : [];
303 $("#subscrpt-preview-graph [data-preview-dur]").each(function () {
304 var $node = $(this);
305 var idx = parseInt($node.data("preview-dur"), 10) || 0;
306 var dur = durations[idx];
307 if (dur) {
308 $node.removeClass("wpsubs-p1-node--ghost");
309 $node.find(".wpsubs-p1-node__title").text(dur.name);
310 $node.find(".wpsubs-p1-node__sub").text(self.billingEvery(dur.freq, dur.interval));
311 } else {
312 $node.addClass("wpsubs-p1-node--ghost");
313 $node.find(".wpsubs-p1-node__title").text(wp.i18n.__("Duration", "subscription"));
314 $node.find(".wpsubs-p1-node__sub").text(wp.i18n.__("Add more", "subscription"));
315 }
316 });
317
318 var prod = this.previewProductName();
319 if (prod) {
320 $("#subscrpt-preview-prod").text(prod);
321 }
322 // The product node's subtitle shows the price range from the set pricing.
323 this.updateProductPriceSub();
324
325 // Which durations are toggled on for the product (page 3). If the rows
326 // aren't rendered yet, treat every duration as on.
327 var enabled = {};
328 $("#subscrpt-connect-durations [data-connect-row]").each(function () {
329 enabled[parseInt($(this).data("connect-dur"), 10)] = $(this).find("[data-connect-enabled]").is(":checked");
330 });
331
332 // Connector lines: muted by default. A plan->duration line colours in
333 // once its duration is filled; a duration->product line also needs a
334 // product to be added and that duration's toggle to be on.
335 var hasProduct = !!prod;
336 $("#subscrpt-preview-graph [data-line-dur]").each(function () {
337 var $line = $(this);
338 var idx = parseInt($line.data("line-dur"), 10) || 0;
339 var active = !!durations[idx];
340 if ($line.attr("data-line-to") === "prod") {
341 active = active && hasProduct && enabled[idx] !== false;
342 }
343 $line.toggleClass("is-active", active);
344 });
345 },
346
347 // ----- Durations (accordion) -----
348
349 // The "billing every" value: "1 month", "3 days".
350 billingEvery: function (freq, interval) {
351 var n = parseInt(freq, 10) || 1;
352 var units = {
353 /* translators: %d: number of days. */
354 day: wp.i18n._n("%d day", "%d days", n, "subscription"),
355 /* translators: %d: number of weeks. */
356 week: wp.i18n._n("%d week", "%d weeks", n, "subscription"),
357 /* translators: %d: number of months. */
358 month: wp.i18n._n("%d month", "%d months", n, "subscription"),
359 /* translators: %d: number of years. */
360 year: wp.i18n._n("%d year", "%d years", n, "subscription"),
361 };
362 return wp.i18n.sprintf(units[interval] || units.month, n);
363 },
364
365 // "1 month" -> "Every Month", "3 days" -> "Every 3 Days". A count of 1 has
366 // its own string: in some languages the singular plural form also covers
367 // 21, 31… and would drop the number.
368 durationName: function (freq, interval) {
369 var n = parseInt(freq, 10) || 1;
370 if (n === 1) {
371 var single = {
372 day: wp.i18n.__("Every Day", "subscription"),
373 week: wp.i18n.__("Every Week", "subscription"),
374 month: wp.i18n.__("Every Month", "subscription"),
375 year: wp.i18n.__("Every Year", "subscription"),
376 };
377 return single[interval] || single.month;
378 }
379 var many = {
380 /* translators: %d: number of days. */
381 day: wp.i18n._n("Every %d Day", "Every %d Days", n, "subscription"),
382 /* translators: %d: number of weeks. */
383 week: wp.i18n._n("Every %d Week", "Every %d Weeks", n, "subscription"),
384 /* translators: %d: number of months. */
385 month: wp.i18n._n("Every %d Month", "Every %d Months", n, "subscription"),
386 /* translators: %d: number of years. */
387 year: wp.i18n._n("Every %d Year", "Every %d Years", n, "subscription"),
388 };
389 return wp.i18n.sprintf(many[interval] || many.month, n);
390 },
391
392 initDurations: function () {
393 if (!$("#subscrpt-durations [data-dur]").length) {
394 this.addDuration();
395 }
396 this.refreshDurControls();
397 },
398
399 addDuration: function (e) {
400 if (e && e.preventDefault) {
401 e.preventDefault();
402 }
403 if ($("#subscrpt-durations [data-dur]").length >= this.MAX_DURATIONS) {
404 return;
405 }
406 var tpl = document.getElementById("subscrpt-duration-tpl");
407 if (!tpl || !tpl.content) {
408 return;
409 }
410 $("#subscrpt-durations").append(tpl.content.cloneNode(true));
411 var card = $("#subscrpt-durations [data-dur]").last();
412 // Wire up the cloned cadence picker (adv-select).
413 if (window.WPSubsAdvSelect) {
414 window.WPSubsAdvSelect.init(card[0]);
415 }
416 this.syncDurName(card, true);
417 this.openDuration(card);
418 this.refreshDurControls();
419 this.updatePreview();
420 },
421
422 openDuration: function (card) {
423 $("#subscrpt-durations [data-dur]").removeClass("is-open");
424 card.addClass("is-open");
425 },
426
427 onDurToggle: function (e) {
428 if ($(e.target).closest("[data-dur-remove]").length) {
429 return;
430 }
431 var card = $(e.currentTarget).closest("[data-dur]");
432 if (card.hasClass("is-open")) {
433 card.removeClass("is-open");
434 } else {
435 this.openDuration(card);
436 }
437 },
438
439 onDurRemove: function (e) {
440 e.preventDefault();
441 e.stopPropagation();
442 var cards = $("#subscrpt-durations [data-dur]");
443 if (cards.length <= 1) {
444 return;
445 }
446 var card = $(e.currentTarget).closest("[data-dur]");
447 var wasOpen = card.hasClass("is-open");
448 card.remove();
449 if (wasOpen) {
450 $("#subscrpt-durations [data-dur]").last().addClass("is-open");
451 }
452 this.refreshDurControls();
453 this.updatePreview();
454 },
455
456 onDurBillingInput: function (e) {
457 this.syncDurName($(e.target).closest("[data-dur]"), false);
458 this.updatePreview();
459 },
460
461 onDurNameInput: function (e) {
462 var card = $(e.target).closest("[data-dur]");
463 card.attr("data-name-edited", "1");
464 card
465 .find("[data-dur-title]")
466 .text($.trim(card.find("[data-dur-name]").val()) || wp.i18n.__("Duration", "subscription"));
467 this.updatePreview();
468 },
469
470 // The interval value ("day"/"week"/"month"/"year") from a card's cadence
471 // picker — the adv-select's hidden input.
472 durInterval: function (card) {
473 return card.find("[data-dur-interval] input[type=hidden]").val() || "month";
474 },
475
476 // Refresh the auto name from the billing period (unless the user edited it)
477 // and mirror the name into the card header.
478 syncDurName: function (card, force) {
479 var auto = this.durationName(card.find("[data-dur-freq]").val(), this.durInterval(card));
480 if (force || card.attr("data-name-edited") !== "1") {
481 card.find("[data-dur-name]").val(auto);
482 }
483 card.find("[data-dur-title]").text($.trim(card.find("[data-dur-name]").val()) || auto);
484 },
485
486 refreshDurControls: function () {
487 var count = $("#subscrpt-durations [data-dur]").length;
488 $("#subscrpt-durations").toggleClass("has-multiple", count > 1);
489 // The preview has 3 duration slots — no more durations past that.
490 $("#subscrpt-btn-add-duration").toggle(count < this.MAX_DURATIONS);
491 },
492
493 collectDurations: function () {
494 var self = this;
495 var out = [];
496 $("#subscrpt-durations [data-dur]").each(function () {
497 var $c = $(this);
498 var freq = parseInt($c.find("[data-dur-freq]").val(), 10) || 1;
499 var interval = self.durInterval($c);
500 var name = $.trim($c.find("[data-dur-name]").val()) || self.durationName(freq, interval);
501 out.push({ freq: freq, interval: interval, name: name });
502 });
503 return out;
504 },
505
506 // Nothing is created here — just validate and move on. The plan, durations
507 // and product are all created together on the final step.
508 nextFromDurations: function (e) {
509 e.preventDefault();
510 if (!$.trim($("#subscrpt_plan_title").val())) {
511 window.alert(wp.i18n.__("Please enter a plan name.", "subscription"));
512 this.switchSection(1);
513 return;
514 }
515 if (!this.collectDurations().length) {
516 window.alert(wp.i18n.__("Please add at least one duration.", "subscription"));
517 return;
518 }
519 this.switchSection(3);
520 },
521
522 // ----- Page 3: connect to a product -----
523
524 // Which connect mode is active. Without existing products the only option
525 // is creating a new one.
526 currentConnectMode: function () {
527 if (!this.hasProducts) {
528 return "new";
529 }
530 var active = $(".wpsubs-connect-mode-card.active");
531 return active.length ? active.data("mode") : "existing";
532 },
533
534 selectConnectMode: function (e) {
535 var card = $(e.currentTarget);
536 var mode = card.data("mode");
537 $(".wpsubs-connect-mode-card").removeClass("active");
538 card.addClass("active");
539 $("#subscrpt-connect-existing").toggle(mode === "existing");
540 $("#subscrpt-connect-new").toggle(mode === "new");
541 this.updatePreview();
542 },
543
544 openProductSearch: function () {
545 $(".wpsubs-p2-product-search__item").show();
546 $(".wpsubs-p2-product-search__empty").hide();
547 $("#subscrpt-product-search-dropdown").show();
548 },
549
550 filterProducts: function (e) {
551 var q = $(e.target).val().toLowerCase().trim();
552 $("#subscrpt-product-search-dropdown").show();
553 var visible = 0;
554 $(".wpsubs-p2-product-search__item").each(function () {
555 var name = $(this).data("name") ? String($(this).data("name")).toLowerCase() : "";
556 var sku = $(this).data("sku") ? String($(this).data("sku")).toLowerCase() : "";
557 if (!q || name.indexOf(q) >= 0 || sku.indexOf(q) >= 0) {
558 $(this).show();
559 visible++;
560 } else {
561 $(this).hide();
562 }
563 });
564 $(".wpsubs-p2-product-search__empty").toggle(visible === 0);
565 },
566
567 onProductPick: function (e) {
568 var item = $(e.currentTarget);
569 // Products already attached to a plan can't be picked.
570 if (item.attr("data-connected") === "1") {
571 return;
572 }
573 var id = String(item.data("id"));
574 var name = item.data("name") || "";
575 var price = item.data("price") != null ? String(item.data("price")) : "";
576 var type = item.data("type") || "";
577 var sku = item.data("sku") ? String(item.data("sku")) : "";
578
579 $("#subscrpt-existing-product-hidden").val(id);
580 $("#subscrpt-product-search-dropdown").hide();
581
582 var initials = name
583 .split(" ")
584 .filter(Boolean)
585 .slice(0, 2)
586 .map(function (w) {
587 return w[0].toUpperCase();
588 })
589 .join("");
590 /* translators: %s: product SKU. */
591 var meta = [sku ? wp.i18n.sprintf(wp.i18n.__("SKU %s", "subscription"), sku) : null, type]
592 .filter(Boolean)
593 .join(" · ");
594
595 $("#p3-chip-avatar").text(initials || "?");
596 $("#p3-chip-name").text(name);
597 $("#p3-chip-meta").text(meta);
598 $("#subscrpt-product-select-wrap").hide();
599 $("#subscrpt-selected-product-chip").show();
600
601 // Prefill any empty duration price with the product's price as a starting
602 // point; leave prices the user already typed alone.
603 if (price) {
604 $("#subscrpt-connect-durations [data-connect-price]").each(function () {
605 if (!$.trim($(this).val())) {
606 $(this).val(parseFloat(price).toFixed(2));
607 }
608 });
609 }
610
611 this.selectedProductName = name;
612 this.updatePreview();
613 },
614
615 clearProduct: function () {
616 $("#subscrpt-existing-product-hidden").val("");
617 $("#subscrpt-product-search-input").val("");
618 $(".wpsubs-p2-product-search__item").show();
619 $("#subscrpt-selected-product-chip").hide();
620 $("#subscrpt-product-select-wrap").show();
621 this.selectedProductName = "";
622 },
623
624 // Snapshot the current price/toggle of each duration row, keyed by index,
625 // so re-rendering doesn't lose what the user typed.
626 readConnectState: function () {
627 var state = {};
628 $("#subscrpt-connect-durations [data-connect-row]").each(function () {
629 var idx = parseInt($(this).data("connect-dur"), 10);
630 state[idx] = {
631 price: $(this).find("[data-connect-price]").val(),
632 enabled: $(this).find("[data-connect-enabled]").is(":checked"),
633 };
634 });
635 return state;
636 },
637
638 // Build one pricing row per duration (from step 2), preserving any values
639 // already entered. All rows connect to the single product.
640 renderConnectDurations: function () {
641 var self = this;
642 var tpl = document.getElementById("subscrpt-connect-dur-tpl");
643 if (!tpl || !tpl.content) {
644 return;
645 }
646 var prev = this.readConnectState();
647 var durations = this.collectDurations();
648 var $wrap = $("#subscrpt-connect-durations").empty();
649
650 durations.forEach(function (dur, idx) {
651 var $frag = $(tpl.content.cloneNode(true));
652 var $row = $frag.find("[data-connect-row]");
653 $row.attr("data-connect-dur", idx);
654 $row.find("[data-connect-name]").text(dur.name);
655 $row.find("[data-connect-billing]").text(
656 /* translators: %s: billing period, such as "3 days". */
657 wp.i18n.sprintf(wp.i18n.__("Billing every %s", "subscription"), self.billingEvery(dur.freq, dur.interval)),
658 );
659 if (prev[idx]) {
660 $row.find("[data-connect-price]").val(prev[idx].price);
661 $row.find("[data-connect-enabled]").prop("checked", prev[idx].enabled);
662 }
663 $row.toggleClass("is-off", !$row.find("[data-connect-enabled]").is(":checked"));
664 $wrap.append($frag);
665 });
666 },
667
668 onConnectToggle: function (e) {
669 var $row = $(e.target).closest("[data-connect-row]");
670 $row.toggleClass("is-off", !$(e.target).is(":checked"));
671 this.updatePreview();
672 },
673
674 // Nothing is created here either — validate the product choice and the
675 // per-duration prices, then move to the final step which does all the work.
676 nextFromProduct: function (e) {
677 e.preventDefault();
678
679 if (this.currentConnectMode() === "existing") {
680 if (!$("#subscrpt-existing-product-hidden").val()) {
681 window.alert(wp.i18n.__("Please select a product.", "subscription"));
682 return;
683 }
684 } else if (!$.trim($("#subscrpt_new_product_name").val())) {
685 window.alert(wp.i18n.__("Please enter a product name.", "subscription"));
686 return;
687 }
688
689 var rows = $("#subscrpt-connect-durations [data-connect-row]");
690 var enabledRows = rows.filter(function () {
691 return $(this).find("[data-connect-enabled]").is(":checked");
692 });
693 if (!enabledRows.length) {
694 window.alert(wp.i18n.__("Please keep at least one duration on.", "subscription"));
695 return;
696 }
697 var badPrice = false;
698 enabledRows.each(function () {
699 var p = $.trim($(this).find("[data-connect-price]").val());
700 if (p && (isNaN(parseFloat(p)) || parseFloat(p) < 0)) {
701 badPrice = true;
702 }
703 });
704 if (badPrice) {
705 window.alert(wp.i18n.__("Please enter valid prices.", "subscription"));
706 return;
707 }
708
709 this.switchSection(4);
710 this.finalize();
711 },
712
713 // ----- Page 4: create everything sequentially -----
714
715 // Mark a progress step's state ("is-doing" / "is-done").
716 finalizeStep: function (key, state) {
717 $('[data-finalize-step="' + key + '"]')
718 .removeClass("is-doing is-done")
719 .addClass(state);
720 },
721
722 // Run the whole creation flow in order: plan -> durations -> product +
723 // relations. Each sub-step is skipped if a retry already completed it.
724 finalize: function (e) {
725 if (e && e.preventDefault) {
726 e.preventDefault();
727 }
728 if (this.finalizeRunning) {
729 return;
730 }
731 this.finalizeRunning = true;
732
733 var self = this;
734 $("#subscrpt-finalize-error, #subscrpt-finalize-success").attr("hidden", "hidden");
735 $("#subscrpt-finalize-progress").removeAttr("hidden");
736 $("#subscrpt-link-plans, #subscrpt-link-products").attr("hidden", "hidden");
737 $("[data-finalize-step]").removeClass("is-doing is-done");
738
739 this.ensurePlan()
740 .then(function () {
741 return self.ensureProductAndConnect();
742 })
743 .then(function () {
744 self.finalizeRunning = false;
745 self.showDone(self.finalProductName);
746 })
747 .catch(function (err) {
748 self.finalizeRunning = false;
749 $("#subscrpt-finalize-progress").attr("hidden", "hidden");
750 $("#subscrpt-finalize-error-msg").text(
751 err && err.message ? err.message : wp.i18n.__("Please try again.", "subscription"),
752 );
753 $("#subscrpt-finalize-error").removeAttr("hidden");
754 });
755 },
756
757 termBody: function (dur, groupId, type) {
758 return {
759 plan_group_id: groupId,
760 type: type,
761 title: dur.name,
762 billing_frequency: dur.freq,
763 billing_interval: INTERVAL_TO_INT[dur.interval] || 3,
764 billing_length: 0,
765 free_trial: "",
766 signup_fee: { amount: "" },
767 status: "active",
768 data: { free_trial_interval: "day" },
769 };
770 },
771
772 // Create the plan group + one term per duration (reusing the auto-seeded
773 // draft term for the first). No-op if a retry already created them.
774 ensurePlan: function () {
775 var self = this;
776 if (this.groupId && this.termIds && this.termIds.length) {
777 this.finalizeStep("plan", "is-done");
778 this.finalizeStep("durations", "is-done");
779 return Promise.resolve();
780 }
781
782 var title = $.trim($("#subscrpt_plan_title").val());
783 var type = $("#subscrpt-plan-type").val() || "recurring";
784 var durations = this.collectDurations();
785
786 this.finalizeStep("plan", "is-doing");
787 return this.api("POST", "/groups", { title: title, type: type, product_type: 1, status: "active" }).then(
788 function (group) {
789 self.groupId = group.id;
790 self.planTitle = title;
791 self.billingText = durations[0].name;
792 self.finalizeStep("plan", "is-done");
793 self.finalizeStep("durations", "is-doing");
794
795 var seeded = group.plans && group.plans.length ? group.plans[0] : null;
796 var termIds = [];
797 var chain = durations.reduce(function (promise, dur, idx) {
798 return promise.then(function () {
799 var body = self.termBody(dur, group.id, type);
800 if (idx === 0 && seeded && seeded.id) {
801 return self.api("PUT", "/terms/" + seeded.id, body).then(function () {
802 termIds.push(seeded.id);
803 });
804 }
805 return self.api("POST", "/terms", body).then(function (term) {
806 termIds.push(term.id);
807 });
808 });
809 }, Promise.resolve());
810
811 return chain.then(function () {
812 self.termIds = termIds;
813 self.finalizeStep("durations", "is-done");
814 });
815 },
816 );
817 },
818
819 // Create the product (if new) and connect the plan to it. No-op if a retry
820 // already connected it.
821 ensureProductAndConnect: function () {
822 var self = this;
823 if (this.relationsCreated) {
824 this.finalizeStep("product", "is-done");
825 return Promise.resolve();
826 }
827 this.finalizeStep("product", "is-doing");
828 return this.ensureProduct()
829 .then(function () {
830 return self.createRelations();
831 })
832 .then(function () {
833 self.relationsCreated = true;
834 self.finalizeStep("product", "is-done");
835 });
836 },
837
838 // Resolve the product id — an existing selection, or a newly created one
839 // (name only; each duration carries its own price on the relation).
840 ensureProduct: function () {
841 var self = this;
842 if (this.finalProductId) {
843 return Promise.resolve();
844 }
845
846 if (this.currentConnectMode() === "existing") {
847 this.finalProductId = $("#subscrpt-existing-product-hidden").val();
848 this.finalProductName = this.selectedProductName || wp.i18n.__("Product", "subscription");
849 return Promise.resolve();
850 }
851
852 var name = $.trim($("#subscrpt_new_product_name").val());
853 this.finalProductName = name;
854 return new Promise(function (resolve, reject) {
855 $.post(
856 self.cfg.ajax_url,
857 {
858 action: "subscrpt_create_wizard_product",
859 nonce: $("#subscrpt_wizard_nonce").val(),
860 product_name: name,
861 },
862 function (response) {
863 if (response && response.success) {
864 self.finalProductId = response.data.product_id;
865 resolve();
866 } else {
867 reject(
868 new Error(
869 (response && response.data && response.data.message) ||
870 wp.i18n.__("Could not create the product.", "subscription"),
871 ),
872 );
873 }
874 },
875 ).fail(function () {
876 reject(new Error(wp.i18n.__("Server error. Please try again.", "subscription")));
877 });
878 });
879 },
880
881 // Connect the product to each toggled-on duration, using that duration's
882 // own price. Durations toggled off get no relation.
883 createRelations: function () {
884 var self = this;
885 var rows = $("#subscrpt-connect-durations [data-connect-row]");
886 var calls = [];
887 (this.termIds || []).forEach(function (tid, idx) {
888 var $row = rows.filter('[data-connect-dur="' + idx + '"]');
889 var enabled = $row.length ? $row.find("[data-connect-enabled]").is(":checked") : true;
890 if (!enabled) {
891 return;
892 }
893 var price = $row.length ? $.trim($row.find("[data-connect-price]").val()) : "";
894 calls.push(
895 self.api("POST", "/relations", {
896 plan_id: tid,
897 oid: parseInt(self.finalProductId, 10),
898 vid: 0,
899 type: 1,
900 status: "active",
901 exclude: false,
902 data: { regular_price: price, sale_price: "", discount_value: 0 },
903 }),
904 );
905 });
906 return Promise.all(calls);
907 },
908
909 showDone: function (productName) {
910 if (productName) {
911 $("#subscrpt-preview-prod").text(productName);
912 }
913 $("#subscrpt-finalize-progress, #subscrpt-finalize-error").attr("hidden", "hidden");
914 $("#subscrpt-finalize-success").removeAttr("hidden");
915 $("#subscrpt-link-plans, #subscrpt-link-products, #subscrpt-btn-finish").removeAttr("hidden");
916 $("#subscrpt-preview-graph").attr("data-active", "done");
917 this.updatePreview();
918 },
919
920 restart: function (e) {
921 e.preventDefault();
922 var self = this;
923 $.post(
924 this.cfg.ajax_url,
925 {
926 action: "subscrpt_reset_wizard",
927 nonce: $("#subscrpt_wizard_nonce").val(),
928 },
929 function () {
930 self.groupId = 0;
931 self.termIds = [];
932 self.planTitle = "";
933 self.billingText = "";
934 self.finalProductId = "";
935 self.finalProductName = "";
936 self.relationsCreated = false;
937 self.finalizeRunning = false;
938 // Reset the final step back to its progress state for a fresh run.
939 $("#subscrpt-finalize-success, #subscrpt-finalize-error").attr("hidden", "hidden");
940 $("#subscrpt-finalize-progress").removeAttr("hidden");
941 $("[data-finalize-step]").removeClass("is-doing is-done");
942 $("#subscrpt-link-plans, #subscrpt-link-products, #subscrpt-btn-finish").attr("hidden", "hidden");
943 $("#subscrpt_new_product_name").val("");
944 $("#subscrpt-connect-durations").empty();
945 $("#subscrpt-durations").empty();
946 self.initDurations();
947 self.clearProduct();
948 // Reset the preview nodes back to their placeholders.
949 $("#subscrpt-preview-prod").text(wp.i18n.__("Product", "subscription"));
950 self.reachedDurations = false;
951 $("#subscrpt-preview-graph [data-preview-dur]")
952 .addClass("wpsubs-p1-node--ghost")
953 .find(".wpsubs-p1-node__title")
954 .text(wp.i18n.__("Duration", "subscription"));
955 $("#subscrpt-preview-graph [data-preview-dur] .wpsubs-p1-node__sub").text(
956 wp.i18n.__("Add more", "subscription"),
957 );
958 self.switchSection(1);
959 },
960 );
961 },
962 };
963
964 $(document).ready(function () {
965 Wizard.init();
966 });
967 })(jQuery);
968