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