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
← All changes | assets/js/admin/onboarding-wizard.js +755 -413 1.10.4 → 2.2.0 View file →
@@ -1,364 +1,550 @@
1 1 /**
2 - * Onboarding Wizard — SPA-style
3 - * All pages rendered at once; JS controls section visibility
4 - * PHP values passed via localized script data (see Menu::enqueue_admin_assets)
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`.
5 15 */
6 16 (function ($) {
7 17 "use strict";
8 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 +
9 24 var Wizard = {
10 - currentPage: 1,
11 - ajaxUrl: "",
12 - subscriptionsUrl: "",
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,
13 40
14 41 init: function () {
15 - this.ajaxUrl = subscrpt_wizard.ajax_url;
16 - this.subscriptionsUrl = subscrpt_wizard.subscriptions_url;
42 + this.cfg = window.subscrpt_wizard || {};
43 + this.hasProducts = $("#subscrpt-has-products").val() === "1";
44 + this.autoName = $.trim($("#subscrpt_plan_title").val());
17 45 this.bindEvents();
18 - this.showRelevantProductSection();
19 - this.initPageIndicator();
20 46 this.initLivePreview();
21 - this.initStepperState();
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);
22 53 },
23 54
24 - initPageIndicator: function () {
25 - this.currentPage = parseInt($("#subscrpt-current-page").text(), 10) || 1;
26 - },
55 + // ----- REST helper -----
27 56
28 - initStepperState: function () {
29 - var page = parseInt($("#subscrpt-wizard-page").val(), 10) || 1;
30 - if (page > 1) {
31 - $("#subscrpt-stepper").show();
32 - $(".wpsubs-wizard-stepper__step").removeClass("active done");
33 - $(".wpsubs-wizard-stepper__step").each(function () {
34 - var step = parseInt($(this).data("step"), 10);
35 - if (step < page) {
36 - $(this).addClass("done");
37 - } else if (step === page) {
38 - $(this).addClass("active");
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.");
39 70 }
71 + return data;
40 72 });
41 - }
73 + });
42 74 },
43 75
76 + // ----- Events -----
77 +
44 78 bindEvents: function () {
45 - // Page 1
46 - $(document).on("click", "#subscrpt-btn-start", $.proxy(this.goToPage2, this));
79 + // Page 0 (welcome).
80 + $(document).on("click", "#subscrpt-btn-start", $.proxy(this.goToPage, this, 1));
47 81 $(document).on("click", "#subscrpt-btn-skip", $.proxy(this.skip, this));
48 82
49 - // Page 2: product toggle
50 - $(document).on("click", "#subscrpt-btn-create-new", $.proxy(this.showNewProduct, this));
51 - $(document).on("click", "#subscrpt-btn-use-existing", $.proxy(this.showExistingProduct, this));
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));
52 87
53 - // Page 2: product search
54 - $(document).on("focus", "#subscrpt-product-search-input", $.proxy(this.openProductSearch, this));
55 - $(document).on("input", "#subscrpt-product-search-input", $.proxy(this.filterProducts, this));
56 - $(document).on("click", ".p2-product-search__item", $.proxy(this.onProductItemClick, this));
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));
57 108 $(document).on("click", function (e) {
58 - if (!$(e.target).closest(".p2-product-search").length) {
109 + if (!$(e.target).closest(".wpsubs-p2-product-search").length) {
59 110 $("#subscrpt-product-search-dropdown").hide();
60 111 }
61 112 });
62 - $(document).on("click", "#subscrpt-btn-clear-product", $.proxy(this.clearProductSelection, this));
63 - $(document).on("click", ".p2-variation-item", $.proxy(this.onVariationSelect, this));
64 113
65 - // Page 2: navigation
66 - $(document).on("click", "#subscrpt-btn-back", $.proxy(this.goToPage1, this));
67 - $(document).on("click", "#subscrpt-btn-save", $.proxy(this.savePage2, this));
68 -
69 - // Page 3
114 + // Page 4 (finish).
115 + $(document).on("click", "#subscrpt-btn-retry-finalize", $.proxy(this.finalize, this));
70 116 $(document).on("click", "#subscrpt-btn-add-another", $.proxy(this.restart, this));
71 - $(document).on("click", "#subscrpt-btn-start-over", $.proxy(this.restart, this));
72 117 },
73 118
74 - // ----- Page transitions -----
119 + // ----- Navigation -----
75 120
76 - goToPage1: function () {
77 - this.switchSection(1);
121 + goToPage: function (pageNum, e) {
122 + if (e && e.preventDefault) {
123 + e.preventDefault();
124 + }
125 + this.switchSection(pageNum);
78 126 },
79 127
80 - goToPage2: function () {
81 - this.switchSection(2);
82 - },
128 + switchSection: function (pageNum) {
129 + $("#subscrpt-wizard-page").val(pageNum);
83 130
84 - goToPage3: function (productId) {
85 - $("#subscrpt-product-id").val(productId || 0);
86 - this.switchSection(3);
87 - },
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 + }
88 136
89 - switchSection: function (pageNum) {
90 - this.currentPage = pageNum;
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);
91 141
92 - $("#subscrpt-wizard-page").val(pageNum);
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 + });
93 151
94 - if (pageNum === 1) {
95 - $("#subscrpt-stepper").hide();
96 - } else {
97 - $("#subscrpt-stepper").show();
98 - $(".wpsubs-wizard-stepper__step").removeClass("active done");
99 - $(".wpsubs-wizard-stepper__step").each(function () {
100 - var step = parseInt($(this).data("step"), 10);
101 - if (step < pageNum) {
102 - $(this).addClass("done");
103 - }
104 - });
105 - $('.wpsubs-wizard-stepper__step[data-step="' + pageNum + '"]').addClass("active");
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();
106 158 }
107 159
108 - $(".wizard-section").removeClass("active");
109 - $("#subscrpt-section-" + pageNum).addClass("active");
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);
110 170 },
111 171
112 - // ----- Page 1 actions -----
113 -
114 172 skip: function (e) {
115 173 e.preventDefault();
116 - window.location.href = this.subscriptionsUrl;
174 + window.location.href = this.cfg.subscriptions_url;
117 175 },
118 176
119 - // ----- Page 2 actions -----
177 + // ----- Page 1: plan type + name -----
120 178
121 - showNewProduct: function (e) {
122 - e.preventDefault();
123 - $(".p2-option-card, .product-toggle-btn").removeClass("active");
124 - $(e.currentTarget).addClass("active");
125 - $("#subscrpt-existing-product-fields").hide();
126 - // Restore name field editability
127 - $("#subscrpt_product_name").prop("readonly", false).val("");
128 - $("#subscrpt_product_price").val("");
129 - $("#subscrpt-btn-save").html("Create product &rsaquo;");
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 +
130 193 this.updatePreview();
131 194 },
132 195
133 - showExistingProduct: function (e) {
196 + nextFromPlan: function (e) {
134 197 e.preventDefault();
135 - $(".p2-option-card, .product-toggle-btn").removeClass("active");
136 - $(e.currentTarget).addClass("active");
137 - $("#subscrpt-existing-product-fields").show();
138 - $("#subscrpt-btn-save").html("Update product &rsaquo;");
139 - // If already has a selection, re-show chip
140 - var selectedVal = $("#subscrpt_existing_product").val();
141 - if (selectedVal) {
142 - this.showProductChip($("#subscrpt_existing_product option:selected"));
198 + if (!$.trim($("#subscrpt_plan_title").val())) {
199 + window.alert("Please enter a plan name.");
200 + return;
143 201 }
202 + this.switchSection(2);
144 203 },
145 204
146 - openProductSearch: function () {
147 - $(".p2-product-search__item").show();
148 - $(".p2-product-search__empty").hide();
149 - $("#subscrpt-product-search-dropdown").show();
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);
150 215 },
151 216
152 - filterProducts: function (e) {
153 - var q = $(e.target).val().toLowerCase().trim();
154 - $("#subscrpt-product-search-dropdown").show();
155 - var visible = 0;
156 - $(".p2-product-search__item").each(function () {
157 - var name = $(this).data("name") ? $(this).data("name").toLowerCase() : "";
158 - var sku = $(this).data("sku") ? String($(this).data("sku")).toLowerCase() : "";
159 - if (!q || name.indexOf(q) >= 0 || sku.indexOf(q) >= 0) {
160 - $(this).show();
161 - visible++;
162 - } else {
163 - $(this).hide();
164 - }
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 + });
165 234 });
166 - $(".p2-product-search__empty").toggle(visible === 0);
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 + });
167 247 },
168 248
169 - onProductItemClick: function (e) {
170 - var item = $(e.currentTarget);
171 - if (item.hasClass("p2-product-search__item--locked")) {
172 - return;
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 || "";
173 254 }
174 - var id = String(item.data("id"));
175 - var name = item.data("name") || "";
176 - var price = item.data("price") != null ? String(item.data("price")) : "";
177 - var type = item.data("type") || "";
178 - var sku = item.data("sku") ? String(item.data("sku")) : "";
179 - var productType = item.data("product-type") || "";
255 + return $.trim($("#subscrpt_new_product_name").val());
256 + },
180 257
181 - $("#subscrpt-existing-product-hidden").val(id);
182 - $("#subscrpt-product-search-dropdown").hide();
183 - this.showProductChip(name, price, type, sku);
184 - $("#subscrpt_product_name").val(name);
185 - $("#subscrpt_product_price").val(price);
186 -
187 - // Reset variation picker state
188 - $("#subscrpt-variation-id-hidden").val("");
189 -
190 - if (productType === "variable") {
191 - // Variation picker handles subscription field autofill
192 - this.fetchAndShowVariations(id);
193 - } else {
194 - $("#subscrpt-variation-picker-wrap").hide();
195 -
196 - // Autofill subscription fields from product meta
197 - var billingPeriod = item.data("billing-period");
198 - var billingPer = item.data("billing-per");
199 - var trialPer = item.data("trial-per");
200 - var signupFee = item.data("signup-fee");
201 -
202 - if (billingPeriod) {
203 - this.setAdvSelectValue("#subscrpt-billing-period-select", String(billingPeriod));
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;
204 266 }
205 - if (billingPer) {
206 - $("#subscrpt_billing_per").val(billingPer);
207 - $("#subscrpt_billing_per_visible").val(billingPer);
267 + var raw = $.trim($(this).find("[data-connect-price]").val());
268 + if (raw === "") {
269 + return;
208 270 }
209 - if (trialPer !== undefined && trialPer !== "") {
210 - $("#subscrpt_trial_timing_per").val(trialPer);
271 + var n = parseFloat(raw);
272 + if (!isNaN(n) && n >= 0) {
273 + prices.push(n);
211 274 }
212 - if (signupFee !== undefined && signupFee !== "") {
213 - $("#subscrpt_signup_fee").val(signupFee);
214 - }
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);
215 285 }
286 + $("#subscrpt-preview-prod-sub").text(text);
287 + },
216 288
217 - this.updatePreview();
218 - },
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");
219 294
220 - fetchAndShowVariations: function (productId) {
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.
221 297 var self = this;
222 - var wrap = $("#subscrpt-variation-picker-wrap");
223 - var list = $("#subscrpt-variation-picker-list");
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 + });
224 313
225 - wrap.show();
226 - list.html(
227 - '<p class="p2-variation-picker__loading">' +
228 - (window.subscrpt_wizard_i18n ? subscrpt_wizard_i18n.loading : "Loading variations…") +
229 - "</p>",
230 - );
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();
231 320
232 - $.post(
233 - this.ajaxUrl,
234 - {
235 - action: "subscrpt_get_product_variations",
236 - nonce: $("#subscrpt_wizard_nonce").val(),
237 - product_id: productId,
238 - },
239 - function (response) {
240 - if (response.success && response.data && response.data.length) {
241 - self.renderVariationPicker(response.data);
242 - } else {
243 - wrap.hide();
244 - }
245 - },
246 - ).fail(function () {
247 - wrap.hide();
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");
248 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 + });
249 341 },
250 342
251 - renderVariationPicker: function (variations) {
252 - var self = this;
253 - var symbol = (window.subscrpt_wizard && subscrpt_wizard.currency_symbol) || "$";
254 - var html = '<p class="p2-variation-picker__label">Select a variation</p>';
255 - html += '<div class="p2-variation-picker__list">';
343 + // ----- Durations (accordion) -----
256 344
257 - variations.forEach(function (v) {
258 - var meta = v.sku ? "SKU " + v.sku : "";
259 - var priceDisplay =
260 - v.price !== "" && v.price !== null && v.price !== undefined ? symbol + parseFloat(v.price).toFixed(2) : "";
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 + },
261 350
262 - html +=
263 - '<div class="p2-variation-item"' +
264 - ' data-id="' +
265 - self.escAttr(String(v.id)) +
266 - '"' +
267 - ' data-label="' +
268 - self.escAttr(v.label) +
269 - '"' +
270 - ' data-price="' +
271 - self.escAttr(String(v.price !== null && v.price !== undefined ? v.price : "")) +
272 - '"' +
273 - ' data-sku="' +
274 - self.escAttr(v.sku || "") +
275 - '"' +
276 - ' data-billing-period="' +
277 - self.escAttr(v.billing_period || "") +
278 - '"' +
279 - ' data-billing-per="' +
280 - self.escAttr(String(v.billing_per || "1")) +
281 - '"' +
282 - ' data-trial-per="' +
283 - self.escAttr(String(v.trial_per !== null && v.trial_per !== undefined ? v.trial_per : "")) +
284 - '"' +
285 - ' data-signup-fee="' +
286 - self.escAttr(String(v.signup_fee || "")) +
287 - '">' +
288 - '<div class="p2-variation-item__check">&#10003;</div>' +
289 - '<div class="p2-variation-item__info">' +
290 - '<p class="p2-variation-item__name">' +
291 - self.escHtml(v.label) +
292 - "</p>" +
293 - (meta ? '<p class="p2-variation-item__meta">' + self.escHtml(meta) + "</p>" : "") +
294 - "</div>" +
295 - (priceDisplay ? '<span class="p2-variation-item__price">' + self.escHtml(priceDisplay) + "</span>" : "") +
296 - "</div>";
297 - });
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 + },
298 358
299 - html += "</div>";
300 - $("#subscrpt-variation-picker-list").html(html);
301 - $("#subscrpt-variation-picker-wrap").show();
359 + initDurations: function () {
360 + if (!$("#subscrpt-durations [data-dur]").length) {
361 + this.addDuration();
362 + }
363 + this.refreshDurControls();
302 364 },
303 365
304 - onVariationSelect: function (e) {
305 - var item = $(e.currentTarget);
306 - $(".p2-variation-item").removeClass("selected");
307 - item.addClass("selected");
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 + },
308 388
309 - $("#subscrpt-variation-id-hidden").val(String(item.data("id")));
389 + openDuration: function (card) {
390 + $("#subscrpt-durations [data-dur]").removeClass("is-open");
391 + card.addClass("is-open");
392 + },
310 393
311 - // Autofill subscription fields and price from variation meta
312 - var price = item.data("price");
313 - var billingPeriod = item.data("billing-period");
314 - var billingPer = item.data("billing-per");
315 - var trialPer = item.data("trial-per");
316 - var signupFee = item.data("signup-fee");
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 + },
317 405
318 - if (price !== undefined && price !== "") {
319 - $("#subscrpt_product_price").val(price);
406 + onDurRemove: function (e) {
407 + e.preventDefault();
408 + e.stopPropagation();
409 + var cards = $("#subscrpt-durations [data-dur]");
410 + if (cards.length <= 1) {
411 + return;
320 412 }
321 - if (billingPeriod) {
322 - this.setAdvSelectValue("#subscrpt-billing-period-select", String(billingPeriod));
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");
323 418 }
324 - if (billingPer) {
325 - $("#subscrpt_billing_per").val(billingPer);
326 - $("#subscrpt_billing_per_visible").val(billingPer);
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);
327 447 }
328 - if (trialPer !== undefined && trialPer !== "") {
329 - $("#subscrpt_trial_timing_per").val(trialPer);
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;
330 479 }
331 - if (signupFee !== undefined && signupFee !== "") {
332 - $("#subscrpt_signup_fee").val(signupFee);
480 + if (!this.collectDurations().length) {
481 + window.alert("Please add at least one duration.");
482 + return;
333 483 }
484 + this.switchSection(3);
485 + },
334 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");
335 506 this.updatePreview();
336 507 },
337 508
338 - escHtml: function (str) {
339 - return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
509 + openProductSearch: function () {
510 + $(".wpsubs-p2-product-search__item").show();
511 + $(".wpsubs-p2-product-search__empty").hide();
512 + $("#subscrpt-product-search-dropdown").show();
340 513 },
341 514
342 - escAttr: function (str) {
343 - return String(str)
344 - .replace(/&/g, "&amp;")
345 - .replace(/"/g, "&quot;")
346 - .replace(/'/g, "&#39;")
347 - .replace(/</g, "&lt;")
348 - .replace(/>/g, "&gt;");
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);
349 530 },
350 531
351 - setAdvSelectValue: function (selector, value) {
352 - var el = $(selector);
353 - if (!el.length) return;
354 - var menuItem = el.find('.wpsubs-adv-select__item[data-value="' + value + '"]');
355 - var label = menuItem.length ? menuItem.find(".wpsubs-adv-select__item-label").text().trim() : value;
356 - el.find('input[type="hidden"]').val(value);
357 - el.find(".wpsubs-adv-select__label").text(label);
358 - },
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")) : "";
359 543
360 - showProductChip: function (name, price, type, sku) {
544 + $("#subscrpt-existing-product-hidden").val(id);
545 + $("#subscrpt-product-search-dropdown").hide();
546 +
361 547 var initials = name
362 548 .split(" ")
363 549 .filter(Boolean)
364 550 .slice(0, 2)
@@ -365,208 +551,364 @@
365 551 .map(function (w) {
366 552 return w[0].toUpperCase();
367 553 })
368 554 .join("");
555 + var meta = [sku ? "SKU " + sku : null, type].filter(Boolean).join(" · ");
369 556
370 - var meta = [sku ? "SKU " + sku : null, type, price ? "$" + parseFloat(price).toFixed(2) : null]
371 - .filter(Boolean)
372 - .join(" · ");
373 -
374 - $("#p2-chip-avatar").text(initials);
375 - $("#p2-chip-name").text(name);
376 - $("#p2-chip-meta").text(meta);
557 + $("#p3-chip-avatar").text(initials || "?");
558 + $("#p3-chip-name").text(name);
559 + $("#p3-chip-meta").text(meta);
377 560 $("#subscrpt-product-select-wrap").hide();
378 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();
379 575 },
380 576
381 - clearProductSelection: function () {
577 + clearProduct: function () {
382 578 $("#subscrpt-existing-product-hidden").val("");
383 579 $("#subscrpt-product-search-input").val("");
384 - $(".p2-product-search__item").show();
385 - $(".p2-product-search__empty").hide();
386 - $("#subscrpt-product-search-dropdown").hide();
580 + $(".wpsubs-p2-product-search__item").show();
387 581 $("#subscrpt-selected-product-chip").hide();
388 582 $("#subscrpt-product-select-wrap").show();
389 - $("#subscrpt_product_name").val("");
390 - $("#subscrpt_product_price").val("");
391 - // Reset variation picker
392 - $("#subscrpt-variation-picker-wrap").hide();
393 - $("#subscrpt-variation-picker-list").empty();
394 - $("#subscrpt-variation-id-hidden").val("");
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"));
395 630 this.updatePreview();
396 631 },
397 632
398 - showRelevantProductSection: function () {
399 - var activeBtn = $(".product-toggle-btn.active");
400 - var mode = activeBtn.length ? activeBtn.data("mode") : "new";
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();
401 637
402 - if (mode === "existing") {
403 - $("#subscrpt-existing-product-fields").show();
404 - var selectedId = $("#subscrpt-existing-product-hidden").val();
405 - if (selectedId) {
406 - var item = $(".p2-product-search__item[data-id='" + selectedId + "']");
407 - if (item.length) {
408 - var name = item.data("name") || "";
409 - var price = item.data("price") != null ? String(item.data("price")) : "";
410 - var type = item.data("type") || "";
411 - var sku = item.data("sku") ? String(item.data("sku")) : "";
412 - this.showProductChip(name, price, type, sku);
413 - }
638 + if (this.currentConnectMode() === "existing") {
639 + if (!$("#subscrpt-existing-product-hidden").val()) {
640 + window.alert("Please select a product.");
641 + return;
414 642 }
415 - } else {
416 - $("#subscrpt-existing-product-fields").hide();
643 + } else if (!$.trim($("#subscrpt_new_product_name").val())) {
644 + window.alert("Please enter a product name.");
645 + return;
417 646 }
418 - },
419 647
420 - // ----- Live preview -----
421 -
422 - initLivePreview: function () {
423 - var self = this;
424 - $(document).on("input", "#subscrpt_product_name", function () {
425 - self.updatePreview();
648 + var rows = $("#subscrpt-connect-durations [data-connect-row]");
649 + var enabledRows = rows.filter(function () {
650 + return $(this).find("[data-connect-enabled]").is(":checked");
426 651 });
427 - $(document).on("input", "#subscrpt_product_price", function () {
428 - self.updatePreview();
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 + }
429 662 });
430 - $(document).on("wpsubs:select", "#subscrpt-billing-period-select", function () {
431 - self.updatePreview();
432 - });
663 + if (badPrice) {
664 + window.alert("Please enter valid prices.");
665 + return;
666 + }
667 +
668 + this.switchSection(4);
669 + this.finalize();
433 670 },
434 671
435 - updatePreview: function () {
436 - var name = $("#subscrpt_product_name").val() || "Your product";
437 - var price = $("#subscrpt_product_price").val() || "0.00";
438 - var period = $("input[name='subscrpt_billing_period']").val() || "months";
672 + // ----- Page 4: create everything sequentially -----
439 673
440 - $("#p2-preview-name").text(name);
441 - $("#p2-preview-price").text(parseFloat(price).toFixed(2));
442 - $("#p2-preview-period").text(period);
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);
443 679 },
444 680
445 - // ----- Save & validate -----
446 -
447 - savePage2: function (e) {
448 - e.preventDefault();
449 -
450 - if (!this.validatePage2()) {
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) {
451 688 return;
452 689 }
690 + this.finalizeRunning = true;
453 691
454 - var productMode = $(".product-toggle-btn.active").data("mode") || "new";
455 - var confirmMsg =
456 - productMode === "new"
457 - ? "Create this subscription product?"
458 - : "Apply these subscription settings to the selected product?";
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");
459 697
460 - if (!confirm(confirmMsg)) {
461 - return;
462 - }
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 + },
463 713
464 - var data = {
465 - action: "subscrpt_save_wizard_page2",
466 - nonce: $("#subscrpt_wizard_nonce").val(),
467 - product_mode: $(".product-toggle-btn.active").data("mode") || "new",
468 - product_name: $("#subscrpt_product_name").val(),
469 - product_price: $("#subscrpt_product_price").val(),
470 - existing_product_id: $("#subscrpt-existing-product-hidden").val(),
471 - variation_id: $("#subscrpt-variation-id-hidden").val(),
472 - timing_option: $("#subscrpt_timing_option").val(),
473 - billing_per: $("#subscrpt_billing_per").val(),
474 - billing_period: $("input[name='subscrpt_billing_period']").val(),
475 - trial_timing_per: $("#subscrpt_trial_timing_per").val(),
476 - signup_fee: $("#subscrpt_signup_fee").val(),
477 - // hidden compat fields
478 - trial_enabled: 0,
479 - trial_timing_option: $("input[name='subscrpt_trial_timing_option']").val() || "days",
480 - length_enabled: 0,
481 - length_per: "",
482 - length_option: "months",
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" },
483 726 };
727 + },
484 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 () {
485 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 + }
486 738
487 - $.post(this.ajaxUrl, data, function (response) {
488 - if (response.success) {
489 - window.location.reload();
490 - } else {
491 - alert(response.data.message || "Something went wrong. Please try again.");
492 - }
493 - }).fail(function () {
494 - alert("Server error. Please try again.");
495 - });
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 + );
496 774 },
497 775
498 - validatePage2: function () {
499 - var isValid = true;
500 - var messages = [];
501 - var productMode = $(".product-toggle-btn.active").data("mode") || "new";
502 - var productName = $("#subscrpt_product_name").val().trim();
503 - var price = $("#subscrpt_product_price").val().trim();
504 - var existing = $("#subscrpt-existing-product-hidden").val();
505 - var billing = $("input[name='subscrpt_billing_period']").val();
506 -
507 - if (productMode === "new") {
508 - if (!productName) {
509 - messages.push("Product name is required.");
510 - isValid = false;
511 - }
512 - } else {
513 - if (!existing) {
514 - messages.push("Please select an existing product.");
515 - isValid = false;
516 - }
517 - if (
518 - existing &&
519 - $("#subscrpt-variation-picker-wrap").is(":visible") &&
520 - !$("#subscrpt-variation-id-hidden").val()
521 - ) {
522 - messages.push("Please select a variation.");
523 - isValid = false;
524 - }
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();
525 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 + },
526 794
527 - if (!price || isNaN(parseFloat(price)) || parseFloat(price) < 0) {
528 - messages.push("Please enter a valid price.");
529 - isValid = false;
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();
530 801 }
531 802
532 - if (!billing) {
533 - messages.push("Please select a billing period.");
534 - isValid = false;
803 + if (this.currentConnectMode() === "existing") {
804 + this.finalProductId = $("#subscrpt-existing-product-hidden").val();
805 + this.finalProductName = this.selectedProductName || "Product";
806 + return Promise.resolve();
535 807 }
536 808
537 - if (!isValid) {
538 - alert(messages.join("\n"));
539 - }
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 + },
540 834
541 - return isValid;
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);
542 861 },
543 862
544 - // ----- Page 3 actions -----
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 + },
545 873
546 874 restart: function (e) {
547 875 e.preventDefault();
548 876 var self = this;
549 877 $.post(
550 - this.ajaxUrl,
878 + this.cfg.ajax_url,
551 879 {
552 880 action: "subscrpt_reset_wizard",
553 881 nonce: $("#subscrpt_wizard_nonce").val(),
554 882 },
555 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");
556 910 self.switchSection(1);
557 - $("#subscrpt-current-page").text("1");
558 - $("#subscrpt_product_name").val("");
559 - $("#subscrpt_product_price").val("");
560 - $("#subscrpt_timing_option").val("never");
561 - $("#subscrpt_billing_per").val("1");
562 - $("input[name='subscrpt_billing_period']").val("months");
563 - $("#subscrpt_trial_timing_per").val("0");
564 - $("#subscrpt_signup_fee").val("");
565 - // Reset chip
566 - $("#subscrpt-selected-product-chip").hide();
567 - $("#subscrpt-product-select-wrap").show();
568 - $("#subscrpt_existing_product").val("");
569 911 },
570 912 );
571 913 },
572 914 };