PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.0.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.0.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 1.4.1 1.4.2 1.5.0 1.5.1 All 61 releases
subscription / assets / js / admin / plan-forms.js

plan-forms.js in Subscriptions for WooCommerce with Stripe Recurring Payments 2.0.0, at assets/js/admin/plan-forms.js

605 lines 20.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Shared plan-group + selling-plan (term) form logic.
3 *
4 * Owns the Create-Plan-Group modal and the Add / Edit Duration modal:
5 * plan-type selection, field collection, the /groups and /terms REST writes,
6 * and prefilling. Used by BOTH the Plans admin screen
7 * (assets/js/admin/plans.js) and the product-editor Subscription tab
8 * (assets/js/admin/product-plans.js), so the term payload contract lives in a
9 * single place.
10 *
11 * On success it does NOT navigate. It closes the modal and dispatches a DOM
12 * event so each host page decides what happens next:
13 * - "subscrpt:group-created" detail: { group }
14 * - "subscrpt:term-saved" detail: { groupId, termId, editing }
15 *
16 * Config is read from window.subscrptPlanForms (restUrl, nonce, i18n),
17 * localized on every page that renders these modals.
18 */
19 (function () {
20 "use strict";
21
22 var cfg = window.subscrptPlanForms || {};
23 var i18n = cfg.i18n || {};
24
25 // day/week/month/year ⇄ billing_interval integer.
26 var INTERVAL_TO_INT = { day: 1, week: 2, month: 3, year: 4 };
27 var INT_TO_INTERVAL = { 1: "day", 2: "week", 3: "month", 4: "year" };
28
29 // REST calls, the button-busy lock and every message here come from the
30 // shared component (admin-components/save.js).
31 var save = window.WPSubsSave.bind({ restUrl: cfg.restUrl, nonce: cfg.nonce, i18n: i18n });
32 var api = save.api;
33 var setLoading = save.busy;
34
35 /* ------------------------------------------------------------------ *
36 * One-time purchase: the prices follow the toggle.
37 * ------------------------------------------------------------------ */
38
39 /**
40 * The block one of these controls belongs to. Three shapes carry it — a
41 * section below the table in the product editor, and on the Plans screen a
42 * table row (variable) or a card (simple) — so they all carry one hook.
43 *
44 * @param {HTMLElement} el A control inside the block.
45 * @return {HTMLElement|null}
46 */
47 function oneTimeScope(el) {
48 return el.closest("[data-subscrpt-onetime]");
49 }
50
51 /**
52 * Fill a one-time block's empty price fields from the product's current
53 * WooCommerce price.
54 *
55 * The one-time price IS the product's native price, so an empty field is not
56 * a choice — it is the merchant being asked to retype what the product
57 * already costs. The General tab's inputs are the source because they carry
58 * the price typed in this session, which no server render can know about.
59 * Only empty fields are filled, so a saved or hand-entered price stands.
60 *
61 * On the Plans screen those inputs do not exist and this does nothing; that
62 * screen seeds its fields server-side instead.
63 *
64 * @param {HTMLElement} scope A one-time row or card.
65 */
66 function seedOneTimePrices(scope) {
67 [
68 ["price", "_regular_price"],
69 ["offer", "_sale_price"],
70 ].forEach(function (pair) {
71 var input = scope.querySelector('[data-ot-field="' + pair[0] + '"]');
72 var native = document.getElementById(pair[1]);
73 if (!input || !native || "" !== String(input.value).trim()) {
74 return;
75 }
76 var value = String(native.value).trim();
77 if ("" !== value) {
78 input.value = value;
79 }
80 });
81 }
82
83 /**
84 * Show the prices only while one-time purchase is switched on.
85 *
86 * Off, the price fields read as a second regular price sitting under the
87 * plan's own — the merchant has no way to tell they do not apply. Revealing
88 * them on the toggle says which is which.
89 *
90 * @param {HTMLElement} scope A one-time row or card.
91 */
92 function syncOneTime(scope) {
93 var toggle = scope.querySelector("[data-subscrpt-onetime-enable]");
94 if (!toggle) {
95 return;
96 }
97 var on = toggle.checked;
98
99 if (on) {
100 seedOneTimePrices(scope);
101 }
102
103 scope.querySelectorAll("[data-subscrpt-onetime-price]").forEach(function (el) {
104 el.style.display = on ? "" : "none";
105 });
106
107 // The card keeps its whole body behind the toggle, prices and all.
108 var body = scope.querySelector("[data-subscrpt-onetime-body]");
109 if (body) {
110 body.style.display = on ? "" : "none";
111 }
112 }
113
114 // Lives here because both the Plans screen and the product editor load this
115 // module, and both render the block.
116 document.addEventListener("change", function (e) {
117 var toggle = e.target.closest("[data-subscrpt-onetime-enable]");
118 var scope = toggle && oneTimeScope(toggle);
119 if (scope) {
120 syncOneTime(scope);
121 }
122 });
123
124 /**
125 * Open a modal by id via the shared helper.
126 *
127 * @param {string} id Modal id.
128 */
129 function openModal(id) {
130 if (window.WPSubsModal && typeof window.WPSubsModal.open === "function") {
131 window.WPSubsModal.open(id);
132 }
133 }
134
135 /**
136 * Close a modal by id via the shared helper.
137 *
138 * @param {string} id Modal id.
139 */
140 function closeModal(id) {
141 if (window.WPSubsModal && typeof window.WPSubsModal.close === "function") {
142 window.WPSubsModal.close(id);
143 }
144 }
145
146 /**
147 * Read the current value of an advanced-select (its hidden input).
148 *
149 * @param {HTMLElement} root .wpsubs-adv-select root.
150 * @return {string}
151 */
152 function advValue(root) {
153 var hidden = root.querySelector('input[type="hidden"]');
154 return hidden ? hidden.value : "";
155 }
156
157 /**
158 * Programmatically set an advanced-select's value + visible label.
159 *
160 * @param {HTMLElement} root .wpsubs-adv-select root.
161 * @param {string} value Option value to select.
162 */
163 function setAdvSelect(root, value) {
164 value = value == null ? "" : String(value);
165 var hidden = root.querySelector('input[type="hidden"]');
166 if (hidden) {
167 hidden.value = value;
168 }
169 var label = root.querySelector(".wpsubs-adv-select__label");
170 var item = root.querySelector('.wpsubs-adv-select__item[data-value="' + value + '"]');
171 if (label) {
172 label.textContent = item ? item.textContent.trim() : root.getAttribute("data-placeholder") || "";
173 }
174 }
175
176 /**
177 * Collect all [data-subscrpt-field] values within a scope into a flat map.
178 *
179 * @param {HTMLElement} scope Container (the term modal).
180 * @return {Object}
181 */
182 function collectFields(scope) {
183 var out = {};
184 scope.querySelectorAll("[data-subscrpt-field]").forEach(function (el) {
185 var key = el.getAttribute("data-subscrpt-field");
186 if (el.classList.contains("wpsubs-adv-select")) {
187 out[key] = advValue(el);
188 } else if (el.type === "checkbox") {
189 out[key] = el.checked;
190 } else if (el.type === "radio") {
191 if (el.checked) {
192 out[key] = el.value;
193 }
194 } else {
195 out[key] = el.value;
196 }
197 });
198 return out;
199 }
200
201 /**
202 * Map the term modal's flat fields to a /terms payload.
203 *
204 * @param {Object} f Flat field map.
205 * @param {string} groupId Plan group id.
206 * @param {string} groupType Plan group type key.
207 * @return {Object} REST payload.
208 */
209 function termPayload(f, groupId, groupType) {
210 var data = {
211 free_trial_interval: f.free_trial_interval || "day",
212 };
213
214 // Recurring Delivery: separate delivery schedule + sync toggle.
215 if (typeof f.delivery_sync !== "undefined") {
216 data.delivery_sync = !!f.delivery_sync;
217 if (data.delivery_sync && typeof f.delivery_day !== "undefined") {
218 data.delivery_day = f.delivery_day;
219 }
220 }
221 if (typeof f.delivery_frequency !== "undefined") {
222 var deliveryFreq = parseInt(f.delivery_frequency, 10);
223 if (!String(f.delivery_frequency).trim() || isNaN(deliveryFreq) || deliveryFreq < 1) {
224 // Empty delivery schedule -> mirror the billing schedule.
225 data.delivery_frequency = parseInt(f.billing_frequency, 10) || 1;
226 data.delivery_interval = f.billing_interval || "month";
227 } else {
228 data.delivery_frequency = deliveryFreq;
229 data.delivery_interval = f.delivery_interval || "month";
230 }
231 }
232
233 // Split Payment: number of payments + access-ends timing.
234 if (typeof f.installment_count !== "undefined") {
235 data.installment_count = Math.max(2, parseInt(f.installment_count, 10) || 2);
236 }
237 if (typeof f.access_ends !== "undefined") {
238 data.access_ends = f.access_ends || "lifetime";
239 if ("custom" === data.access_ends) {
240 data.access_custom_value = parseInt(f.access_custom_value, 10) || 1;
241 data.access_custom_interval = f.access_custom_interval || "month";
242 }
243 }
244
245 return {
246 plan_group_id: parseInt(groupId, 10),
247 type: groupType || "recurring",
248 title: f.title || "",
249 billing_frequency: parseInt(f.billing_frequency, 10) || 1,
250 billing_interval: INTERVAL_TO_INT[f.billing_interval] || 3,
251 billing_length: parseInt(f.billing_length, 10) || 0,
252 free_trial: f.free_trial || "",
253 signup_fee: { amount: f.signup_fee_amount || "" },
254 status: "active",
255 data: data,
256 };
257 }
258
259 /**
260 * Prefill the term modal from a fetched term, or clear it for "add".
261 *
262 * @param {HTMLElement} modal Term modal.
263 * @param {Object|null} term Term row, or null to reset.
264 */
265 function fillTermModal(modal, term) {
266 modal.querySelectorAll("[data-subscrpt-field]").forEach(function (el) {
267 var key = el.getAttribute("data-subscrpt-field");
268 var adv = el.classList.contains("wpsubs-adv-select");
269
270 // Reset to defaults for "add".
271 if (!term) {
272 if (adv) {
273 setAdvSelect(el, el.getAttribute("data-default-value") || "");
274 } else if (el.type === "checkbox") {
275 el.checked = false;
276 } else if (el.type !== "radio") {
277 el.value = key === "billing_frequency" || key === "billing_length" ? el.defaultValue : "";
278 }
279 return;
280 }
281
282 var data = term.data || {};
283 switch (key) {
284 case "title":
285 el.value = term.title || "";
286 break;
287 case "billing_frequency":
288 el.value = term.billing_frequency || 1;
289 break;
290 case "billing_interval":
291 setAdvSelect(el, INT_TO_INTERVAL[term.billing_interval] || "month");
292 break;
293 case "billing_length":
294 el.value = term.billing_length || 0;
295 break;
296 case "free_trial":
297 el.value = term.free_trial || "";
298 break;
299 case "free_trial_interval":
300 setAdvSelect(el, data.free_trial_interval || "day");
301 break;
302 case "signup_fee_amount":
303 el.value = term.signup_fee && term.signup_fee.amount ? term.signup_fee.amount : "";
304 break;
305 case "delivery_sync":
306 el.checked = !!data.delivery_sync;
307 break;
308 case "delivery_day":
309 setAdvSelect(el, typeof data.delivery_day !== "undefined" ? data.delivery_day : "1");
310 break;
311 case "delivery_frequency":
312 el.value = data.delivery_frequency || 1;
313 break;
314 case "delivery_interval":
315 setAdvSelect(el, data.delivery_interval || "month");
316 break;
317 case "installment_count":
318 el.value = data.installment_count || 2;
319 break;
320 case "access_ends":
321 setAdvSelect(el, data.access_ends || "lifetime");
322 break;
323 case "access_custom_value":
324 el.value = data.access_custom_value || 1;
325 break;
326 case "access_custom_interval":
327 setAdvSelect(el, data.access_custom_interval || "month");
328 break;
329 default:
330 break;
331 }
332 });
333 modal.setAttribute("data-editing", term ? term.id : "");
334 toggleAccessCustom(modal);
335 toggleDeliveryDay(modal);
336 var title = modal.querySelector("[data-subscrpt-term-title]");
337 if (title) {
338 title.textContent = term ? i18n.editTerm || "Edit Duration" : i18n.addTerm || "Add Duration";
339 }
340 }
341
342 /**
343 * Show the custom access-length inputs only when "Custom" is selected.
344 *
345 * @param {HTMLElement} modal Term modal (or any ancestor of the controls).
346 */
347 function toggleAccessCustom(modal) {
348 if (!modal) {
349 return;
350 }
351 var sel = modal.querySelector('[data-subscrpt-field="access_ends"]');
352 var custom = modal.querySelector("[data-subscrpt-access-custom]");
353 if (!sel || !custom) {
354 return;
355 }
356 var isCustom = "custom" === advValue(sel);
357 custom.style.display = isCustom ? "" : "none";
358
359 // Access ends spans full width unless the custom-duration column is shown.
360 var grid = modal.querySelector("[data-subscrpt-access-grid]");
361 if (grid) {
362 grid.style.gridTemplateColumns = isCustom ? "1fr 2fr" : "1fr";
363 }
364 }
365
366 /**
367 * Enable the delivery-day picker only when Synchronize schedule is on
368 * (it stays visible but disabled otherwise).
369 *
370 * @param {HTMLElement} modal Term modal.
371 */
372 function toggleDeliveryDay(modal) {
373 if (!modal) {
374 return;
375 }
376 var box = modal.querySelector('[data-subscrpt-field="delivery_sync"]');
377 var day = modal.querySelector("[data-subscrpt-delivery-day]");
378 if (!box || !day) {
379 return;
380 }
381 var on = box.checked;
382 day.style.opacity = on ? "" : "0.55";
383 day.style.pointerEvents = on ? "" : "none";
384 }
385
386 /**
387 * Reset the term modal for a specific group and open it (used by callers
388 * that chain "create group → add first plan", e.g. the product editor).
389 *
390 * @param {number|string} groupId Plan group id.
391 * @param {string} groupType Plan group type key.
392 */
393 function openTermModalForGroup(groupId, groupType) {
394 var modal = document.querySelector("[data-subscrpt-term-modal]");
395 if (!modal) {
396 return;
397 }
398 modal.setAttribute("data-group-id", groupId);
399 modal.setAttribute("data-group-type", groupType || "recurring");
400 fillTermModal(modal, null);
401 openModal("subscrpt-term-modal");
402 }
403
404 /* ------------------------------------------------------------------ *
405 * Handlers (delegated on document; each host page renders the modals).
406 * ------------------------------------------------------------------ */
407
408 // Plan-type cards: click to select (skips locked / Pro cards). Selection is a
409 // border + soft brand background - no radio input.
410 document.addEventListener("click", function (e) {
411 var card = e.target.closest(".subscrpt-type-card");
412 if (!card || card.hasAttribute("data-locked")) {
413 return;
414 }
415 var list = card.closest("[data-subscrpt-type-list]");
416 if (!list) {
417 return;
418 }
419 var typeLabels = [];
420 list.querySelectorAll(".subscrpt-type-card").forEach(function (c) {
421 var on = c === card;
422 c.classList.toggle("is-selected", on);
423 c.style.borderColor = on ? "var(--wpsubs-brand)" : "var(--wpsubs-border)";
424 c.style.background = on ? "var(--wpsubs-brand-light)" : "";
425 var icon = c.querySelector(".dashicons");
426 if (icon) {
427 icon.style.color = on ? "var(--wpsubs-brand)" : "var(--wpsubs-text-subtle)";
428 }
429 var lbl = c.getAttribute("data-subscrpt-type-label");
430 if (lbl) {
431 typeLabels.push(lbl);
432 }
433 });
434
435 // Auto-fill the name from the selected type — but only while it is empty or
436 // still holds an auto-generated type label, so a name the user typed (or a
437 // restored one) is never overwritten.
438 var modal = card.closest(".wpsubs-modal");
439 var nameInput = modal ? modal.querySelector("#subscrpt-create-name") : null;
440 var selectedLabel = card.getAttribute("data-subscrpt-type-label");
441 if (nameInput && selectedLabel) {
442 var current = nameInput.value.trim();
443 if (current === "" || typeLabels.indexOf(current) !== -1) {
444 nameInput.value = selectedLabel;
445 }
446 }
447 });
448
449 // Create a plan group. On success, close the modal and let the host decide
450 // where to go next (Plans page redirects to the detail; the product editor
451 // chains into the Add-Selling-Plan modal).
452 document.addEventListener("click", function (e) {
453 var btn = e.target.closest("[data-subscrpt-create-plan]");
454 if (!btn) {
455 return;
456 }
457 e.preventDefault();
458
459 var modal = btn.closest(".wpsubs-modal");
460 var nameInput = modal.querySelector("#subscrpt-create-name");
461 var name = nameInput ? nameInput.value.trim() : "";
462
463 if (!name) {
464 save.notify(i18n.nameRequired, "error");
465 return;
466 }
467
468 // Selected type card (defaults to recurring). The REST controller rejects
469 // non-recurring types unless Pro is active.
470 var selectedCard = modal.querySelector(".subscrpt-type-card.is-selected");
471 var planType = selectedCard ? selectedCard.getAttribute("data-subscrpt-type") : "recurring";
472
473 // Deferred mode (product-editor wizard): don't create yet. Hand the group
474 // details to step 2, which creates the group + first plan atomically on
475 // submit, so nothing is created until both steps are filled.
476 if (modal.hasAttribute("data-subscrpt-defer")) {
477 closeModal("subscrpt-create-plan");
478 document.dispatchEvent(
479 new CustomEvent("subscrpt:group-step", {
480 detail: { title: name, type: planType || "recurring" },
481 }),
482 );
483 return;
484 }
485
486 setLoading(btn, true);
487 api("POST", "/groups", {
488 title: name,
489 type: planType || "recurring",
490 product_type: 1,
491 status: "active",
492 })
493 .then(function (group) {
494 setLoading(btn, false);
495 if (nameInput) {
496 nameInput.value = "";
497 }
498 closeModal("subscrpt-create-plan");
499 document.dispatchEvent(new CustomEvent("subscrpt:group-created", { detail: { group: group } }));
500 })
501 .catch(function (err) {
502 setLoading(btn, false);
503 save.notify(err.message || i18n.genericError, "error");
504 });
505 });
506
507 // Re-evaluate the custom access row when the selection changes.
508 document.addEventListener("wpsubs:select", function (e) {
509 var root = e.target.closest('[data-subscrpt-field="access_ends"]');
510 if (root) {
511 toggleAccessCustom(root.closest("[data-subscrpt-term-modal]"));
512 }
513 });
514
515 // Toggle the delivery-day picker as the sync checkbox changes.
516 document.addEventListener("change", function (e) {
517 var box = e.target.closest('[data-subscrpt-field="delivery_sync"]');
518 if (box) {
519 toggleDeliveryDay(box.closest("[data-subscrpt-term-modal]"));
520 }
521 });
522
523 // Open the term modal in add or edit mode.
524 document.addEventListener("click", function (e) {
525 var add = e.target.closest("[data-subscrpt-add-term]");
526 var edit = e.target.closest("[data-subscrpt-edit-term]");
527 if (!add && !edit) {
528 return;
529 }
530 var modal = document.querySelector("[data-subscrpt-term-modal]");
531 if (!modal) {
532 return;
533 }
534 if (add) {
535 fillTermModal(modal, null);
536 // The trigger already carries data-wpsubs-modal-open, so the shared
537 // WPSubsModal opens it.
538 return;
539 }
540 e.preventDefault();
541 var termId = edit.getAttribute("data-subscrpt-edit-term");
542 api("GET", "/terms/" + termId)
543 .then(function (term) {
544 fillTermModal(modal, term);
545 openModal("subscrpt-term-modal");
546 })
547 .catch(function (err) {
548 save.notify(err.message || i18n.genericError, "error");
549 });
550 });
551
552 // Save the term (create or update). On success, close the modal and let the
553 // host decide (Plans page reloads; the product editor refreshes in place).
554 document.addEventListener("click", function (e) {
555 var btn = e.target.closest("[data-subscrpt-term-submit]");
556 if (!btn) {
557 return;
558 }
559 var modal = btn.closest("[data-subscrpt-term-modal]");
560 var groupId = modal.getAttribute("data-group-id");
561 var groupType = modal.getAttribute("data-group-type");
562 var editing = modal.getAttribute("data-editing");
563 var payload = termPayload(collectFields(modal), groupId, groupType);
564
565 if (!payload.title) {
566 save.notify(i18n.nameRequired, "error");
567 return;
568 }
569
570 // Deferred mode (product-editor wizard): hand the plan payload to the host,
571 // which creates the group + this plan atomically. Nothing is written here.
572 if (modal.hasAttribute("data-subscrpt-defer")) {
573 closeModal("subscrpt-term-modal");
574 document.dispatchEvent(new CustomEvent("subscrpt:term-step", { detail: { payload: payload } }));
575 return;
576 }
577
578 setLoading(btn, true);
579 var req = editing ? api("PUT", "/terms/" + editing, payload) : api("POST", "/terms", payload);
580 req
581 .then(function (term) {
582 setLoading(btn, false);
583 closeModal("subscrpt-term-modal");
584 document.dispatchEvent(
585 new CustomEvent("subscrpt:term-saved", {
586 detail: { groupId: groupId, termId: term && term.id, editing: editing },
587 }),
588 );
589 })
590 .catch(function (err) {
591 setLoading(btn, false);
592 save.notify(err.message || i18n.genericError, "error");
593 });
594 });
595
596 window.WPSubsPlanForms = {
597 api: api,
598 seedOneTimePrices: seedOneTimePrices,
599 fillTermModal: fillTermModal,
600 openModal: openModal,
601 closeModal: closeModal,
602 openTermModalForGroup: openTermModalForGroup,
603 };
604 })();
605