1(function () { 2 'use strict'; 3 var cfg = window.ppaccessFrontend || {}; 4 5 // Debug: Log config on load 6 console.log('PaywallProject: Config loaded', { 7 has_rest: !!cfg.rest, 8 has_captcha_provider: !!cfg.captcha_provider, 9 captcha_provider: cfg.captcha_provider, 10 has_captcha_site_key: !!cfg.captcha_site_key 11 }); 12 13 if (!cfg.rest) { return; } 14 var core = window.ppaccessCore || {}; 15 16 // Refresh the nonce on page load so cached pages don't use a stale one. 17 // The /public/nonce endpoint is uncached and always returns a fresh value. 18 // We fire this immediately and queue any REST calls behind the promise. 19 // Account token: stored locally so it never bleeds into non-account requests. 20 // Seeded from the server-baked cfg.account_token (set in wp_localize_script 21 // for logged-in users) and refreshed by each /public/nonce response. 22 var accountToken = cfg.account_token || null; 23 24 var nonceReady = fetch( 25 (cfg.rest || '').replace(/\/$/, '') + '/public/nonce', 26 { method: 'GET', credentials: 'same-origin' } 27 ).then(function (r) { 28 return r.ok ? r.json() : null; 29 }).then(function (data) { 30 if (data && data.nonce) { cfg.nonce = data.nonce; } 31 if (data && data.account_token) { accountToken = data.account_token; } 32 }).catch(function () { /* keep the page-load nonce as fallback */ }); 33 34 function postJSON(path, body, opts) { 35 // Wait for the nonce refresh before sending any authenticated request. 36 return nonceReady.then(function () { 37 return core.api.postJSON(cfg, path, body, opts); 38 }); 39 } 40 41 function postJSONWithAccountToken(path, body) { 42 // Like postJSON but sends X-PPAccess-Account-Token instead of X-WP-Nonce. 43 // Used only for My Account actions so WP core's rest_cookie_check_errors 44 // (which fires on any X-WP-Nonce header that fails verification) cannot 45 // block the request before our permission_callback runs. 46 return nonceReady.then(function () { 47 return core.api.postJSON(cfg, path, body, { accountToken: accountToken }); 48 }); 49 } 50 51 function addLoginToken(url, token) { 52 if (!token || !url) { return url; } 53 var sep = url.indexOf('?') >= 0 ? '&' : '?'; 54 return url + sep + 'ppaccess_login=' + encodeURIComponent(token); 55 } 56 57 function setResult(form, success, message) { 58 var box = form.querySelector('.ppaccess-form-result'); 59 if (!box) { return; } 60 box.classList.remove('success', 'error'); 61 box.classList.add(success ? 'success' : 'error'); 62 box.textContent = message; 63 } 64 65 function addCaptcha(form, body, action) { 66 // Ensure hidden input exists for reCAPTCHA v3 67 if (cfg && cfg.captcha_provider === 'recaptcha_v3' && cfg.captcha_site_key) { 68 var input = form.querySelector('input[name="captcha_token"]'); 69 if (!input) { 70 input = document.createElement('input'); 71 input.type = 'hidden'; 72 input.name = 'captcha_token'; 73 form.appendChild(input); 74 } 75 } 76 77 return core.captcha.add(cfg, form, body, action).then(function (result) { 78 // Debug: log captcha result 79 if (cfg && cfg.captcha_provider !== 'none') { 80 console.log('PaywallProject Captcha: Result for action "' + action + '":', { 81 has_token: !!(result && (result.captcha_token || result['g-recaptcha-response'] || result['cf-turnstile-response'])), 82 provider: cfg.captcha_provider 83 }); 84 } 85 return result; 86 }); 87 } 88 89 function formatMoney(cents, currency) { 90 return core.money.format(cfg.currency_format || {}, cents, currency); 91 } 92 93 function discountedAmountCents(coupon, amountCents, currency) { 94 return core.coupon.discountedAmountCents(coupon, amountCents, currency); 95 } 96 97 function amountTargets(form) { 98 var roots = [form]; 99 var section = form.closest ? form.closest('.ppaccess-subscribe') : null; 100 var standalone = form.closest ? form.closest('.ppaccess-standalone-page') : null; 101 if (section) { roots.push(section); } 102 if (standalone) { roots.push(standalone); } 103 104 var targets = []; 105 roots.forEach(function (root) { 106 if (!root || !root.querySelectorAll) { return; } 107 root.querySelectorAll('[data-ppaccess-amount-due]').forEach(function (target) { 108 if (targets.indexOf(target) === -1) { 109 targets.push(target); 110 } 111 }); 112 }); 113 return targets; 114 } 115 116 function setAmountDue(form, amountCents) { 117 // If a plan-change preview owns the display (e.g. "no charge today"), 118 // don't let coupon validation or resets overwrite it. 119 if (form.getAttribute('data-ppaccess-amount-locked')) { return; } 120 var currency = form.getAttribute('data-currency') || cfg.currency || 'usd'; 121 // If this subscriber has Stripe credit on account, apply it automatically 122 // to every price update so the displayed amount is always the net charge. 123 var creditCents = parseInt(form.getAttribute('data-ppaccess-credit-cents') || '0', 10); 124 if (creditCents > 0) { 125 amountCents = Math.max(0, amountCents - creditCents); 126 } 127 amountTargets(form).forEach(function (target) { 128 target.textContent = formatMoney(amountCents, currency); 129 }); 130 131 if (form.getAttribute('data-mode') === 'inline' && form.getAttribute('data-recurring') !== '1') { 132 var button = form.querySelector('.ppaccess-subscribe-button'); 133 if (button) { 134 button.textContent = 'Pay ' + formatMoney(amountCents, currency); 135 } 136 } 137 } 138 139 function couponStatus(input) { 140 var parent = input.parentNode; 141 if (!parent) { return null; } 142 var status = parent.querySelector('[data-ppaccess-coupon-status]'); 143 if (status) { return status; } 144 status = document.createElement('p'); 145 status.className = 'ppaccess-coupon-status'; 146 status.setAttribute('data-ppaccess-coupon-status', ''); 147 status.setAttribute('role', 'status'); 148 status.setAttribute('aria-live', 'polite'); 149 input.insertAdjacentElement('afterend', status); 150 return status; 151 } 152 153 function setCouponStatus(input, message, state) { 154 var status = couponStatus(input); 155 if (!status) { return; } 156 status.classList.toggle('is-error', state === 'error'); 157 status.classList.toggle('is-pending', state === 'pending'); 158 status.textContent = message || ''; 159 160 // Also show a compact applied badge next to the first amount-due element 161 // (the "Due today" price in the order summary above the form). 162 var form = input.closest('form'); 163 if (!form) { return; } 164 var root = (form.closest && (form.closest('.ppaccess-standalone-page') || form.closest('.ppaccess-subscribe'))) || form; 165 var priceEl = root.querySelector('[data-ppaccess-amount-due]'); 166 if (!priceEl) { return; } 167 var badge = priceEl.parentNode.querySelector('.ppaccess-coupon-badge'); 168 if (state === 'success' && message) { 169 if (!badge) { 170 badge = document.createElement('span'); 171 badge.className = 'ppaccess-coupon-badge'; 172 badge.style.cssText = 'display:inline-block;margin-left:8px;font-size:12px;font-weight:600;color:#15803d;background:#dcfce7;border-radius:4px;padding:2px 7px;vertical-align:middle'; 173 priceEl.insertAdjacentElement('afterend', badge); 174 } 175 badge.textContent = message; 176 } else if (badge) { 177 badge.remove(); 178 } 179 } 180 181 function normalizeCouponCode(code) { 182 return core.coupon.normalizeCode(code); 183 } 184 185 function initCouponPricing(form) { 186 var input = form.querySelector('input[name="coupon"]'); 187 if (!input) { return; } 188 189 var levelId = parseInt(form.getAttribute('data-level-id') || '0', 10); 190 var currency = form.getAttribute('data-currency') || cfg.currency || 'usd'; 191 if (!levelId) { return; } 192 193 // Read dynamically so that the surcharge notice can update data-amount 194 // before coupons are applied on top. 195 function baseAmount() { 196 return parseInt(form.getAttribute('data-amount') || '0', 10); 197 } 198 199 var timer = null; 200 var requestId = 0; 201 var lastCheckedCode = null; 202 203 function resetCoupon() { 204 requestId++; 205 lastCheckedCode = null; 206 // If a plan-change preview has locked the amount (e.g. "no charge today"), 207 // don't reset back to the base price â the preview owns the display. 208 if (!form.getAttribute('data-ppaccess-amount-locked')) { 209 setAmountDue(form, baseAmount()); 210 } 211 setCouponStatus(input, '', ''); 212 // Re-run the plan-change preview so it reflects the removed coupon. 213 if (typeof form._ppRunPreview === 'function') { form._ppRunPreview(); } 214 } 215 216 function validateCoupon() { 217 var code = (input.value || '').trim(); 218 var normalizedCode = normalizeCouponCode(code); 219 if (!normalizedCode) { 220 resetCoupon(); 221 return; 222 } 223 if (normalizedCode === lastCheckedCode) { 224 return; 225 } 226 227 var thisRequest = ++requestId; 228 setCouponStatus(input, 'Checking coupon...', 'pending'); 229 postJSON('/public/check-coupon', { 230 code: code, 231 level_id: levelId 232 }, { skipToken: true }).then(function (res) { 233 if (thisRequest !== requestId) { return; } 234 if (!res.ok || !res.body || !res.body.valid) { 235 lastCheckedCode = normalizedCode; 236 setAmountDue(form, baseAmount()); 237 setCouponStatus(input, (res.body && res.body.message) || 'That coupon is not val
237id.', 'error'); 238 return; 239 } 240 241 var amount = discountedAmountCents(res.body, baseAmount(), currency); 242 var saved = Math.max(0, baseAmount() - amount); 243 var appliedCode = res.body.code || code.toUpperCase(); 244 input.value = appliedCode; 245 lastCheckedCode = normalizeCouponCode(appliedCode); 246 setAmountDue(form, amount); 247 setCouponStatus(input, 'Coupon ' + appliedCode + ' applied. You saved ' + formatMoney(saved, currency) + '.', 'success'); 248 // Re-run the plan-change preview so it accounts for the newly applied coupon. 249 if (typeof form._ppRunPreview === 'function') { form._ppRunPreview(); } 250 }).catch(function () { 251 if (thisRequest !== requestId) { return; } 252 setAmountDue(form, baseAmount()); 253 setCouponStatus(input, 'Could not check that coupon.', 'error'); 254 }); 255 } 256 257 input.addEventListener('input', function () { 258 window.clearTimeout(timer); 259 if (!(input.value || '').trim()) { 260 resetCoupon(); 261 return; 262 } 263 if (normalizeCouponCode(input.value) !== lastCheckedCode) { 264 requestId++; 265 } 266 timer = window.setTimeout(validateCoupon, 450); 267 }); 268 input.addEventListener('blur', function () { 269 window.clearTimeout(timer); 270 validateCoupon(); 271 }); 272 273 // Auto-apply if the input was pre-filled from a URL parameter. 274 if ((input.value || '').trim()) { 275 validateCoupon(); 276 } 277 278 // Return a handle so other initializers (e.g. surcharge) can reset or 279 // re-validate the coupon total after changing data-amount. 280 return { 281 resetCoupon: resetCoupon, 282 revalidate: function () { 283 lastCheckedCode = null; // force re-check against new base amount 284 validateCoupon(); 285 } 286 }; 287 } 288 289 // Donation / pay-what-you-want levels: keep the form's data-amount and the 290 // displayed total in sync with the amount the buyer types, clamped to the 291 // level's configured range. The server re-clamps authoritatively. 292 function initCustomAmount(form, couponHandle) { 293 var input = form.querySelector('input[name="custom_amount"]'); 294 if (!input) { return; } 295 296 function clampCents() { 297 var value = parseFloat(input.value); 298 if (isNaN(value) || value < 0) { value = 0; } 299 var cents = Math.round(value * 100); 300 var minAttr = parseFloat(input.getAttribute('min')); 301 var maxAttr = parseFloat(input.getAttribute('max')); 302 if (!isNaN(minAttr) && minAttr > 0 && cents < Math.round(minAttr * 100)) { 303 cents = Math.round(minAttr * 100); 304 } 305 if (!isNaN(maxAttr) && maxAttr > 0 && cents > Math.round(maxAttr * 100)) { 306 cents = Math.round(maxAttr * 100); 307 } 308 return cents; 309 } 310 311 function apply(revalidateCoupon) { 312 var cents = clampCents(); 313 form.setAttribute('data-amount', String(cents)); 314 // revalidate() re-applies any coupon against the new base and also 315 // refreshes the displayed amount-due; without a coupon handle just 316 // update the total directly. 317 if (revalidateCoupon && couponHandle && typeof couponHandle.revalidate === 'function') { 318 couponHandle.revalidate(); 319 } else { 320 setAmountDue(form, cents); 321 } 322 } 323 324 // Live total while typing (cheap); re-check the coupon on commit (network). 325 input.addEventListener('input', function () { apply(false); }); 326 input.addEventListener('change', function () { 327 // Snap the visible field to the clamped value so it's clear the minimum 328 // (or maximum) was applied â e.g. typing 9 under a $10 minimum shows 10. 329 var cents = clampCents(); 330 var snapped = cents / 100; 331 var formatted = (cents % 100 === 0) ? String(snapped) : snapped.toFixed(2); 332 if (input.value !== '' && input.value !== formatted) { 333 input.value = formatted; 334 } 335 apply(true); 336 }); 337 apply(false); 338 } 339 340 function bindForm(selector, path, transformBody, loadingText, minLoadingMs) { 341 document.addEventListener('submit', function (event) { 342 var form = event.target; 343 if (!form || !form.matches || !form.matches(selector)) { return; } 344 event.preventDefault(); 345 var submitBtn = form.querySelector('[type="submit"]'); 346 var data = new FormData(form); 347 var body = {}; 348 data.forEach(function (value, key) { body[key] = value; }); 349 if (typeof transformBody === 'function') { body = transformBody(body); } 350 setBtnLoading(submitBtn, true, loadingText); 351 // Hold the loading state for a minimum time so a fast response still reads 352 // as deliberate; the result (and any notice below) is delayed to match. 353 var started = Date.now(); 354 function settle(done) { 355 var wait = Math.max(0, (minLoadingMs || 0) - (Date.now() - started)); 356 if (wait > 0) { setTimeout(done, wait); } else { done(); } 357 } 358 addCaptcha(form, body, 'ppaccess_' + path.replace(/[^a-z0-9]+/gi, '_')).then(function (bodyWithCaptcha) { 359 return postJSON(path, bodyWithCaptcha); 360 }).then(function (res) { 361 settle(function () { 362 if (!res.ok) { 363 setBtnLoading(submitBtn, false); 364 setResult(form, false, (res.body && res.body.message) || 'Request failed.'); 365 return; 366 } 367 setResult(form, true, (res.body && res.body.message) || 'Success.'); 368 // On redirect, keep the spinner spinning through navigation; otherwise restore. 369 if (res.body && res.body.redirect) { window.location = res.body.redirect; return; } 370 setBtnLoading(submitBtn, false); 371 }); 372 }).catch(function () { 373 settle(function () { 374 setBtnLoading(submitBtn, false); 375 setResult(form, false, 'Network error.'); 376 }); 377 }); 378 }); 379 } 380 381 bindForm('#ppaccess-register-form', '/public/register'); 382 bindForm('#ppaccess-login-form', '/public/login', null, 'Signing inâ¦'); 383 bindForm('#ppaccess-lostpassword-form', '/public/lost-password', null, 'Sendingâ¦', 1200); 384 bindForm('#ppaccess-resetpass-form', '/public/reset-password', null, 'Savingâ¦'); 385 386 // Markup for the "account already exists â sign in" step shown in the free 387 // registration slide-up. Shared by both entry points (email-check and the 388 // post-OTP user_exists case) so they stay in sync. Includes a passwordless 389 // "email me a sign-in link" fallback for people who don't remember their 390 // password â the link works in any browser/device (it's a self-contained, 391 // server-validated token, not tied to this session). 392 function existingAccountLoginHtml(email) { 393 return '<p style="margin:0 0 12px;font-size:14px">An account with that email already exists. Sign in to continue:</p>' + 394 '<form class="ppaccess-form ppaccess-login-inline" style="margin:0">' + 395 '<input type="hidden" name="email" value="' + escHtml(email) + '">' + 396 '<div class="ppaccess-form-row"><label>Password</label>' + 397 '<input type="password" name="password" required autocomplete="current-password">' + 398 '</div>' + 399 '<button type="submit" class="ppaccess-button" style="width:100%">Sign in</button>' +
400 '<div class="ppaccess-form-result" role="status" aria-live="polite"></div>' + 401 '</form>' + 402 '<p class="ppaccess-inline-forgot-wrap" style="margin:10px 0 0;font-size:13px;text-align:center">' + 403 '<a href="#" class="ppaccess-inline-forgot" data-email="' + escHtml(email) + '">Forgot your password? Email me a sign-in link</a>' + 404 '</p>'; 405 } 406 407 // Passwordless "email me a sign-in link" fallback in the existing-account step. 408 document.addEventListener('click', function (event) { 409 var link = event.target && event.target.closest ? event.target.closest('.ppaccess-inline-forgot') : null; 410 if (!link) { return; } 411 event.preventDefault(); 412 if (link.getAttribute('data-ppaccess-busy')) { return; } 413 var email = link.getAttribute('data-email') || ''; 414 if (!email) { return; } 415 link.setAttribute('data-ppaccess-busy', '1'); 416 var original = link.textContent; 417 link.textContent = 'Sendingâ¦'; 418 function restoreLink() { 419 link.removeAttribute('data-ppaccess-busy'); 420 link.textContent = 'Could not send the link. Try again'; 421 setTimeout(function () { link.textContent = original; }, 3000); 422 } 423 // Return the user to the article they were on after they click the link. 424 // skipToken: a public endpoint â must not consume a pending checkout token. 425 postJSON('/public/passwordless', { email: email, redirect: window.location.href }, { skipToken: true }) 426 .then(function (res) { 427 if (!res.ok) { restoreLink(); return; } 428 var wrap = link.closest('.ppaccess-inline-forgot-wrap') || link; 429 wrap.textContent = 'Check your email for a link to sign in. The link expires in 30 minutes.'; 430 }) 431 .catch(restoreLink); 432 }); 433 434 // Inline login form injected when a free-registration email already exists. 435 document.addEventListener('submit', function (event) { 436 var form = event.target; 437 if (!form || !form.matches || !form.matches('.ppaccess-login-inline')) { return; } 438 event.preventDefault(); 439 var data = new FormData(form); 440 var body = { email: data.get('email') || '', password: data.get('password') || '' }; 441 var submit = form.querySelector('button[type=submit]'); 442 if (submit) { submit.disabled = true; } 443 addCaptcha(form, body, 'ppaccess_login').then(function (b) { 444 return postJSON('/public/login', b); 445 }).then(function (res) { 446 if (!res.ok) { 447 if (submit) { submit.disabled = false; } 448 setResult(form, false, (res.body && res.body.message) || 'Invalid email or password.'); 449 return; 450 } 451 // Reload so the restriction resolver re-runs with the authenticated session. 452 window.location.reload(); 453 }).catch(function () { 454 if (submit) { submit.disabled = false; } 455 setResult(form, false, 'Network error.'); 456 }); 457 }); 458 459 // Free registration â two-step OTP flow. 460 // Step 1: email submitted â request OTP â show code input. 461 // Step 2: code submitted â verify OTP â final registration submit. 462 document.addEventListener('submit', function (event) { 463 var form = event.target; 464 if (!form || !form.matches) { return; } 465 466 // ââ Step 1: initial email form ââââââââââââââââââââââââââââââââââââââ 467 if (form.matches('.ppaccess-quick-register')) { 468 event.preventDefault(); 469 var emailInput = form.querySelector('[name=email]'); 470 var email = emailInput ? emailInput.value.trim() : ''; 471 if (!email) { setResult(form, false, 'Please enter your email.'); return; } 472 473 var btn = form.querySelector('[type=submit]'); 474 if (btn) { btn.disabled = true; btn.textContent = 'Checkingâ¦'; } 475 setResult(form, true, ''); 476 477 // Check if this email already has an account before sending an OTP. 478 postJSON('/public/free-registration/check-email', { email: email }) 479 .then(function (checkRes) { 480 if (checkRes.ok && checkRes.body && checkRes.body.exists) { 481 // Existing user â show login form immediately, skip OTP. 482 var wrapper0 = document.createElement('div'); 483 wrapper0.className = form.className; 484 wrapper0.style.cssText = form.style.cssText; 485 wrapper0.innerHTML = existingAccountLoginHtml(email); 486 form.parentNode.replaceChild(wrapper0, form); 487 return; 488 } 489 // New email â proceed with OTP. 490 if (btn) { btn.textContent = 'Sending codeâ¦'; } 491 postJSON('/public/free-registration/request-otp', { email: email }) 492 .then(function (res) { 493 if (!res.ok) { 494 if (btn) { btn.disabled = false; btn.textContent = 'Continue'; } 495 setResult(form, false, (res.body && res.body.message) || 'Could not send code.'); 496 return; 497 } 498 // Collect any extra hidden fields (level, source_post_id, etc.) 499 var hiddenFields = ''; 500 var inputs = form.querySelectorAll('input[type=hidden]'); 501 for (var i = 0; i < inputs.length; i++) { 502 hiddenFields += '<input type="hidden" name="' + escHtml(inputs[i].name) + '" value="' + escHtml(inputs[i].value) + '">'; 503 } 504 // Replace the form with the OTP code-entry step. 505 var wrapper = document.createElement('div'); 506 wrapper.className = form.className; 507 wrapper.style.cssText = form.style.cssText; 508 wrapper.innerHTML = 509 '<p style="margin:0 0 12px;font-size:14px">We sent a 6-digit code to <strong>' + escHtml(email) + '</strong>. Enter it below to verify your email.</p>' + 510 '<form class="ppaccess-form ppaccess-otp-verify" style="margin:0" autocomplete="off">' + 511 '<input type="hidden" name="email" value="' + escHtml(email) + '">' + 512 hiddenFields + 513 '<div class="ppaccess-form-row">' + 514 '<label for="ppaccess-otp-code">Verification code</label>' + 515 '<input id="ppaccess-otp-code" type="text" name="otp_code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" placeholder="123456" required autocomplete="one-time-code">' + 516 '</div>' + 517 '<button type="submit" class="ppaccess-button" style="width:100%">Verify & create account</button>' + 518 '<div class="ppaccess-form-result" role="status" aria-l
518ive="polite"></div>' + 519 '</form>' + 520 '<p style="margin:10px 0 0;font-size:13px;text-align:center">' + 521 '<a href="#" class="ppaccess-otp-resend" data-email="' + escHtml(email) + '">Resend code</a>' + 522 '</p>'; 523 form.parentNode.replaceChild(wrapper, form); 524 }) 525 .catch(function () { 526 if (btn) { btn.disabled = false; btn.textContent = 'Continue'; } 527 setResult(form, false, 'Network error. Please try again.'); 528 }); 529 }) 530 .catch(function () { 531 if (btn) { btn.disabled = false; btn.textContent = 'Continue'; } 532 setResult(form, false, 'Network error. Please try again.'); 533 }); 534 return; 535 } 536 537 // ââ Step 2: OTP verify + final submit âââââââââââââââââââââââââââââââ 538 if (form.matches('.ppaccess-otp-verify')) { 539 event.preventDefault(); 540 var otpEmail = (form.querySelector('[name=email]') || {}).value || ''; 541 var otpCode = (form.querySelector('[name=otp_code]') || {}).value || ''; 542 if (!otpCode || otpCode.replace(/\D/g, '').length !== 6) { 543 setResult(form, false, 'Please enter the 6-digit code.'); 544 return; 545 } 546 547 var otpBtn = form.querySelector('[type=submit]'); 548 if (otpBtn) { otpBtn.disabled = true; otpBtn.textContent = 'Verifyingâ¦'; } 549 setResult(form, true, ''); 550 551 postJSON('/public/free-registration/verify-otp', { email: otpEmail, code: otpCode.replace(/\D/g, '') }) 552 .then(function (res) { 553 if (!res.ok) { 554 if (otpBtn) { otpBtn.disabled = false; otpBtn.textContent = 'Verify & create account'; } 555 setResult(form, false, (res.body && res.body.message) || 'Incorrect code.'); 556 return; 557 } 558 // Code verified â collect all fields and submit registration. 559 if (otpBtn) { otpBtn.textContent = 'Creating accountâ¦'; } 560 var formData = new FormData(form); 561 var body = {}; 562 formData.forEach(function (value, key) { body[key] = value; }); 563 body.otp_code = otpCode.replace(/\D/g, ''); 564 return addCaptcha(form, body, 'ppaccess_free_registration_submit').then(function (bodyWithCaptcha) { 565 return postJSON('/public/free-registration/submit', bodyWithCaptcha); 566 }).then(function (submitRes) { 567 if (!submitRes.ok) { 568 if (otpBtn) { otpBtn.disabled = false; otpBtn.textContent = 'Verify & create account'; } 569 setResult(form, false, (submitRes.body && submitRes.body.message) || 'Registration failed.'); 570 return; 571 } 572 if (submitRes.body && submitRes.body.user_exists) { 573 var wrapper2 = document.createElement('div'); 574 wrapper2.className = 'ppaccess-form ppaccess-quick-register'; 575 wrapper2.innerHTML = existingAccountLoginHtml(otpEmail); 576 var otpParent = form.closest('.ppaccess-quick-register') || form.parentNode; 577 otpParent.parentNode.replaceChild(wrapper2, otpParent); 578 return; 579 } 580 document.cookie = 'ppaccess_nag_loc=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax'; 581 if (submitRes.body && submitRes.body.redirect) { 582 window.location = submitRes.body.redirect; 583 } else { 584 window.location.reload(); 585 } 586 }); 587 }) 588 .catch(function () { 589 if (otpBtn) { otpBtn.disabled = false; otpBtn.textContent = 'Verify & create account'; } 590 setResult(form, false, 'Network error. Please try again.'); 591 }); 592 return; 593 } 594 }); 595 596 // Resend OTP code link. 597 document.addEventListener('click', function (event) { 598 var link = event.target; 599 if (!link || !link.matches || !link.matches('.ppaccess-otp-resend')) { return; } 600 event.preventDefault(); 601 var email = link.getAttribute('data-email') || ''; 602 if (!email) { return; } 603 link.textContent = 'Sendingâ¦'; 604 postJSON('/public/free-registration/request-otp', { email: email }) 605 .then(function (res) { 606 link.textContent = res.ok ? 'Code resent!' : 'Could not resend. Try again.'; 607 setTimeout(function () { link.textContent = 'Resend code'; }, 4000); 608 }) 609 .catch(function () { 610 link.textContent = 'Could not resend. Try again.'; 611 setTimeout(function () { link.textContent = 'Resend code'; }, 4000); 612 }); 613 }); 614 615 function initShippingSurchargeNotice(form, couponHandle) { 616 var surchargeAmountCents = parseInt(form.getAttribute('data-surcharge-amount') || '0', 10); 617 if (!surchargeAmountCents) { return; } 618 var localCodes = (form.getAttribute('data-surcharge-postal-codes') || '') 619 .split(',').map(function (c) { return c.trim().toUpperCase().replace(/\s+/g, ''); }
619).filter(Boolean); 620 var postalInput = form.querySelector('[name="shipping_postal_code"]'); 621 if (!postalInput) { return; } 622 623 var originalAmountCents = parseInt(form.getAttribute('data-amount') || '0', 10); 624 var currency = form.getAttribute('data-currency') || cfg.currency || 'usd'; 625 626 var notice = document.createElement('p'); 627 notice.className = 'ppaccess-surcharge-notice'; 628 notice.style.cssText = 'display:none;margin-top:6px;color:#92400e;background:#fef3c7;border:1px solid #fde68a;border-radius:4px;padding:6px 10px;font-size:13px'; 629 postalInput.parentNode.appendChild(notice); 630 631 function update() { 632 var code = postalInput.value.trim().toUpperCase().replace(/\s+/g, ''); 633 var applies = code && (localCodes.length === 0 || localCodes.indexOf(code) === -1); 634 635 var newBase = applies ? originalAmountCents + surchargeAmountCents : originalAmountCents; 636 form.setAttribute('data-amount', String(newBase)); 637 638 if (applies) { 639 notice.textContent = 'A shipping surcharge of ' + formatMoney(surchargeAmountCents, currency) + ' per billing period has been added for your area.'; 640 notice.style.display = 'block'; 641 } else { 642 notice.style.display = 'none'; 643 } 644 645 // Re-validate the coupon against the new base so a pre-filled coupon 646 // isn't cleared when the postal code changes. 647 if (couponHandle) { couponHandle.revalidate(); } 648 else { setAmountDue(form, newBase); } 649 } 650 651 postalInput.addEventListener('input', update); 652 postalInput.addEventListener('change', update); 653 } 654 655 // Stripe requires a customer name whenever a shipping address is sent, so as 656 // soon as the shopper types any shipping field, the first/last name inputs 657 // become required (and revert when every shipping field is cleared again). 658 function initShippingNameRequirement(form) { 659 var shippingInputs = form.querySelectorAll('[name^="shipping_"]'); 660 if (!shippingInputs.length) { return; } 661 var nameInputs = []; 662 ['first_name', 'last_name'].forEach(function (key) { 663 var el = form.querySelector('[name="' + key + '"]'); 664 if (el) { nameInputs.push(el); } 665 }); 666 if (!nameInputs.length) { return; } 667 668 function hasShipping() { 669 for (var i = 0; i < shippingInputs.length; i++) { 670 if ((shippingInputs[i].value || '').trim() !== '') { return true; } 671 } 672 return false; 673 } 674 675 function update() { 676 var required = hasShipping(); 677 nameInputs.forEach(function (el) { 678 if (required) { el.setAttribute('required', 'required'); } 679 else if (!el.hasAttribute('data-ppaccess-req-locked')) { el.removeAttribute('required'); } 680 }); 681 } 682 683 // Remember inputs the level already marks required so we never un-require them. 684 nameInputs.forEach(function (el) { 685 if (el.hasAttribute('required')) { el.setAttribute('data-ppaccess-req-locked', '1'); } 686 }); 687 shippingInputs.forEach(function (el) { 688 el.addEventListener('input', update); 689 el.addEventListener('change', update); 690 }); 691 update(); 692 } 693 694 function checkTosAccepted(form) { 695 var cb = form ? form.querySelector('input[name="tos_accepted"]') : null; 696 return !cb || cb.checked; 697 } 698 699 var subscribeForm = document.getElementById('ppaccess-subscribe-form'); 700 if (subscribeForm) { 701 var mode = subscribeForm.getAttribute('data-mode') || cfg.checkout_mode || 'redirect'; 702 var couponHandle = initCouponPricing(subscribeForm); 703 initShippingSurchargeNotice(subscribeForm, couponHandle); 704 initShippingNameRequirement(subscribeForm); 705 initCustomAmount(subscribeForm, couponHandle); 706 707 // Check if the logged-in user already has a recurring subscription. 708 // If so, show a proration preview before they submit. 709 // Skip for non-recurring levels. 710 var levelSlug = subscribeForm.getAttribute('data-level') || ''; 711 var levelIsRecurring = subscribeForm.getAttribute('data-recurring') === '1'; 712 var levelHasTrial = subscribeForm.getAttribute('data-has-trial') === '1'; 713 if (levelSlug && levelIsRecurring && !levelHasTrial && cfg.nonce && document.body.classList.contains('logged-in')) { 714 initPlanChangePreview(subscribeForm, levelSlug, mode); 715 } 716 717 if ('inline' === mode && typeof Stripe !== 'undefined') { 718 initInlineSubscribe(subscribeForm); 719 } else { 720 subscribeForm.addEventListener('submit', function (event) { 721 event.preventDefault(); 722 var box = subscribeForm.querySelector('.ppaccess-form-result'); 723 var submit = subscribeForm.querySelector('button[type=submit]'); 724 if (!checkTosAccepted(subscribeForm)) { 725 if (box) { box.classList.add('error'); box.textContent = 'Please agree to the terms above before continuing.'; } 726 return; 727 } 728 var data = new FormData(subscribeForm); 729 var body = { level: subscribeForm.getAttribute('data-level') || data.get('level') || '' }; 730 ['email', 'password', 'coupon', 'custom_amount', 'source_post_id'].forEach(function (k) { 731 var v = data.get(k); 732 if (v) { body[k] = v; } 733 }); 734 body.redirect_to = window.location.href; 735 if (submit) { submit.disabled = true; } 736 if (box) { box.classList.remove('success', 'error'); box.textContent = 'Redirecting to Stripeâ¦'; } 737 addCaptcha(subscribeForm, body, 'ppaccess_subscribe').then(function (bodyWithCaptcha) { 738 return postJSON('/public/subscribe', bodyWithCaptcha); 739 }).then(function (res) { 740 if (!res.ok || !res.body || !res.body.redirect) { 741 if (submit) { submit.disabled = false; } 742 if (box) { box.classList.add('error'); box.textContent = (res.body && res.body.message) || 'Could not start checkout.'; } 743 return; 744 } 745 window.location = res.body.redirect; 746 }).catch(function () { 747 if (submit) { submit.disabled = false; } 748 if (box) { box.classList.add('error'); box.textContent = 'Network error.'; } 749 }); 750 }); 751 } 752 } 753 754 function formatMoneyCents(cents, currency) { 755 var abs = Math.abs(cents); 756 var dollars = (abs / 100).toFixed(2); 757 var symbol = currency === 'EUR' ? 'â¬' : currency === 'GBP' ? '£' : '$'; 758 return (cents < 0 ? '-' : '') + symbol + dollars + ' ' + currency.toUpperCase(); 759 } 760 761 function initPlanChangePreview(form, levelSlug, mode) { 762 var box = form.querySelector('.ppaccess-form-result'); 763 var submit = form.querySelector('button[type=submit]'); 764 var banner = document.createElement('div'); 765 banner.className = 'ppaccess-plan-change-preview'; 766 banner.style.cssText = 'display:none;background:#f0f4ff;border:1px solid #c7d2fe;border-radius:6px;padding:14px 16px;margin:12px 0;font-size:14px;line-height:1.5'; 767 768 var reviewStep = form.querySelector('[data-ppaccess-step="review"] .ppaccess-checkout-step-body'); 769 var insertTarget = reviewStep || (submit && submit.parentNode ? submit.parentNode : null); 770 if (insertTarget) { 771 if (reviewStep && submit && submit.parentNode === reviewStep) { 772 reviewStep.insertBefore(banner, submit); 773 } else if (reviewStep) { 774 reviewStep.insertBefore(banner, reviewStep.firstChild); 775 } else if (submit && submit.parentNode) { 776 submit.parentNode.insertBefore(banner, submit); 777 } 778 } 779 780 // Wire up postal code input to re-fetch the preview when the code changes, 781 // so that adding a surcharge-triggering postal code updates the banner live. 782 var postalEl = form ? form.querySelector('[name="shipping_postal_code"]') : null; 783 var postalDebounce; 784 if (postalEl) { 785 postalEl.addEventListener('change', function () { 786 clearTimeout(postalDebounce); 787 postalDebounce = setTimeout(function () { 788 // Clear the amount lock so the re-fetch can update prices. 789 form.removeAttribute('data-ppaccess-amount-locked'); 790 runPreview(postalEl.value.trim()); 791 }, 400); 792 }); 793 } 794 795 var previewSeq = 0; // incremented on each call; stale responses are ignored 796 function runPreview(postalCode, attempt) { 797 attempt = attempt || 1; 798 var maxAttempts = 3; 799 var seq = ++previewSeq; 800 // Retry a transient failure (e.g. a brief Stripe blip surfaced as a 5xx) 801 // with a short backoff, as long as no newer request has superseded us. 802 function retryIfTransient() { 803 if (seq === previewSeq && attempt < maxAttempts) { 804 setTimeout(function () { runPreview(postalCode, attempt + 1); }, 600 * attempt); 805 return true; 806 } 807 return false;
808 } 809 var previewPayload = { level: levelSlug }; 810 if (postalCode) { 811 previewPayload.shipping_postal_code = postalCode; 812 } 813 var couponEl = form ? form.querySelector('input[name="coupon"]') : null; 814 var couponCode = (couponEl ? (couponEl.value || '') : (form ? (form.getAttribute('data-prefill-coupon') || '') : '')).trim(); 815 if (couponCode) { 816 previewPayload.coupon = couponCode; 817 } 818 postJSON('/public/account/preview-plan-change', previewPayload, { preserveToken: true }) 819 .then(function (res) { 820 if (seq !== previewSeq) { return; } // a newer request is in flight â discard 821 if (!res.ok || !res.body) { 822 retryIfTransient(); 823 return; 824 } 825 var b = res.body; 826 827 // Current sub is trialing (or target has a trial): backend will cancel 828 // and start fresh â show a neutral notice, no confirmation needed. 829 if (b.cancel_and_recheckout) { 830 banner.style.display = 'block'; 831 var currentLabel = b.current_is_trial ? 'trial' : 'subscription'; 832 banner.innerHTML = '<p style="margin:0 0 4px"><strong>Switching to ' + escHtml(b.level_name) + '</strong></p>' 833 + '<p style="margin:0">Your current ' + currentLabel + ' will be cancelled and a new subscription will start today.</p>'; 834 if (mode !== 'inline' && submit) { 835 // Redirect mode: go straight to checkout page; backend cancels on arrival. 836 var recheckoutBtn = document.createElement('button'); 837 recheckoutBtn.type = 'button'; 838 recheckoutBtn.className = submit.className; 839 recheckoutBtn.textContent = 'Continue to checkout'; 840 submit.parentNode.replaceChild(recheckoutBtn, submit); 841 recheckoutBtn.addEventListener('click', function () { 842 recheckoutBtn.disabled = true; 843 window.location = b.checkout_url || window.location.href; 844 }); 845 } 846 // Inline mode: submit button is untouched â normal handler runs. 847 return; 848 } 849 850 // Fresh checkout (no active Stripe subscription). If the customer has a 851 // Stripe credit balance, Stripe will apply it automatically â show the 852 // effective amount due rather than the full plan price. 853 if (b.fresh_checkout) { 854 // Stripe customer balance: negative value = credit on account. 855 // Store it on the form so setAmountDue always nets it out, 856 // regardless of whether a coupon is also applied. 857 var freshCreditCents = b.customer_balance_cents ? -b.customer_balance_cents : 0; 858 if (freshCreditCents > 0) { 859 form.setAttribute('data-ppaccess-credit-cents', freshCreditCents); 860 // Re-run setAmountDue with current displayed price to apply credit now. 861 // Use level_price_cents as the base; coupon validation will also call 862 // setAmountDue if a coupon is active, which will apply credit too. 863 setAmountDue(form, b.level_price_cents || 0); 864 // Show a badge so the subscriber knows credit is being applied. 865 var freshCreditRoot = (form.closest && (form.closest('.ppaccess-standalone-page') || form.closest('.ppaccess-subscribe'))) || form; 866 var freshPriceEl = freshCreditRoot.querySelector('[data-ppaccess-amount-due]'); 867 if (freshPriceEl) { 868 var freshBadge = freshPriceEl.parentNode.querySelector('.ppaccess-coupon-badge'); 869 if (!freshBadge) { 870 freshBadge = document.createElement('span'); 871 freshBadge.className = 'ppaccess-coupon-badge'; 872 freshBadge.style.cssText = 'display:inline-block;margin-left:8px;font-size:12px;font-weight:600;color:#15803d;background:#dcfce7;border-radius:4px;padding:2px 7px;vertical-align:middle'; 873 freshPriceEl.insertAdjacentElement('afterend', freshBadge); 874 } 875 freshBadge.textContent = formatMoneyCents(freshCreditCents, b.currency) + ' credit applied'; 876 } 877 } 878 return; 879 } 880 881 // Switching to a non-recurring plan: cancel sub + redirect to one-time checkout. 882 if (b.non_recurring) { 883 banner.style.display = 'block'; 884 banner.innerHTML = '<p style="margin:0 0 8px"><strong>Switching to ' + escHtml(b.level_name) + '</strong></p>' 885 + '<p style="margin:0">Your recurring subscription will be cancelled and you\'ll complete a one-time purchase instead.</p>'; 886 if (submit) { 887 var confirmBtn = document.createElement('button'); 888 confirmBtn.type = 'button'; 889 confirmBtn.className = submit.className; 890 confirmBtn.textContent = 'Cancel subscription & continue'; 891 submit.parentNode.replaceChild(confirmBtn, submit); 892 confirmBtn.addEventListener('click', function () { 893 confirmBtn.disabled = true; 894 if (box) { box.classList.remove('success', 'error'); box.textContent = 'Cancelling subscriptionâ¦'; } 895 var couponInputNR = form.querySelector('input[name="coupon"]'); 896 postJSON('/public/account/change-plan', { level: levelSlug, coupon: couponInputNR ? (couponInputNR.value || '') : '' }) 897 .then(function (res2) { 898 var dest = (res2.body && res2.body.redirect) ? res2.body.redirect : (b.checkout_url || window.location.href); 899 window.location = dest; 900 }).catch(function () { 901 confirmBtn.disabled = false;
902 if (box) { box.classList.add('error'); box.textContent = 'Network error.'; } 903 }); 904 }); 905 } 906 return; 907 } 908 909 var amountText; 910 var headingText; 911 if (b.same_plan) { 912 headingText = 'Updating your ' + escHtml(b.level_name) + ' subscription'; 913 if (b.amount_due > 0) { 914 // A surcharge is being added â write the display first, then lock 915 // so the concurrent coupon revalidation (triggered by the postal 916 // code change event) can't race in and overwrite with the 917 // coupon-discounted plan price. 918 amountText = 'A prorated shipping surcharge of <strong>' + formatMoneyCents(b.amount_due, b.currency) + '</strong> will be charged today.'; 919 amountTargets(form).forEach(function (el) { el.textContent = formatMoneyCents(b.amount_due, b.currency); }); 920 form.setAttribute('data-ppaccess-amount-locked', '1'); 921 } else { 922 amountText = 'No charge today â any changes (such as a coupon) will apply on your next renewal.'; 923 // Lock the amount display so resetCoupon() can't overwrite it. 924 form.setAttribute('data-ppaccess-amount-locked', '1'); 925 // Set all amount-due elements (including the order summary "Due today" 926 // and the review panel) to "â". Both show today's charge, which is $0. 927 // Use amountTargets() so we reach the review panel, which lives outside 928 // the <form> tag in the checkout steps sibling div. 929 amountTargets(form).forEach(function (el) { el.textContent = 'â'; }); 930 } 931 } else { 932 headingText = 'Switching to ' + escHtml(b.level_name); 933 var prorationTotal = typeof b.proration_total === 'number' ? b.proration_total : b.amount_due; 934 if (prorationTotal < 0) { 935 // Downgrade: the unused time on the old plan exceeds the new plan 936 // cost â Stripe will add the difference as credit to the account. 937 var creditBack = -prorationTotal; 938 amountText = 'No charge today â you\'ll receive a <strong>' + formatMoneyCents(creditBack, b.currency) + '</strong> credit applied to your next invoice.'; 939 amountTargets(form).forEach(function (el) { el.textContent = 'â'; }); 940 form.setAttribute('data-ppaccess-amount-locked', '1'); 941 } else if (b.amount_due <= 0) { 942 if (b.starting_balance < 0) { 943 // Credit on account fully covers the prorated charge. 944 amountText = 'No charge today â your account credit covers the cost of switching plans.'; 945 amountTargets(form).forEach(function (el) { el.textContent = 'â'; }); 946 form.setAttribute('data-ppaccess-amount-locked', '1'); 947 } else { 948 // $0 proration with no credit (e.g. upgrading from a $0 plan). 949 return; 950 } 951 } else { 952 // b.amount_due is the actual charge today (prorated), not the 953 // plan renewal price. Write directly then lock so coupon 954 // revalidation can't reset it to the full plan price. 955 amountText = 'You\'ll be charged <strong>' + formatMoneyCents(b.amount_due, b.currency) + '</strong> now (prorated for the remainder of your billing period'; 956 if (b.starting_balance < 0) { 957 amountText += ', including <strong>' + formatMoneyCents(-b.starting_balance, b.currency) + '</strong> account credit'; 958 } 959 amountText += ').'; 960 amountTargets(form).forEach(function (el) { el.textContent = formatMoneyCents(b.amount_due, b.currency); }); 961 form.setAttribute('data-ppaccess-amount-locked', '1'); 962 } 963 } 964 965 banner.style.display = 'block'; 966 banner.innerHTML = '<p style="margin:0 0 8px"><strong>' + headingText + '</strong></p>' 967 + '<p style="margin:0">' + amountText + '</p>'; 968 969 // Append a tax estimate line when Stripe Tax is enabled and there is a 970 // charge today (prorated amount > 0). 971 if (form.getAttribute('data-tax-enabled') === '1' && b.amount_due > 0) { 972 var cardPostal = (function () { 973 var el = form.querySelector('[name="card_postal_code"]'); 974 return el ? el.value.trim() : ''; 975 }()); 976 var rest = String((cfg && cfg.rest) || '').replace(/\/$/, ''); 977 fetch(rest + '/public/nonce', { credentials: 'same-origin' }) 978 .then(function (r) { return r.json().catch(function () { return {}; }); }) 979 .then(function (data) { if (data && data.nonce) { cfg.nonce = data.nonce; } }) 980 .catch(function () {}) 981 .then(function () { 982 return postJSON('/public/calculate-tax', { 983 level_id: parseInt(form.getAttribute('data-level-id') || '0', 10), 984 amount_cents: b.amount_due, 985 address_postal_code: cardPostal, 986 address_country: cardPostal ? 'US' : '', 987 }); 988 }) 989 .then(function (res) { 990 if (res && res.ok && res.body && res.body.tax_display) { 991 var taxP = document.createElement('p'); 992 taxP.style.cssText = 'margin:6px 0 0'; 993 taxP.innerHTML = 'Includes <strong>' + escHtml(res.body.tax_display) + '</strong> in estimated sales tax.'; 994 banner.appendChild(taxP); 995 } 996 }) 997 .catch(function () {}); 998 } 999 1000 // For redirect mode, replace the submit button with a confirmation button 1001 // that calls the change-plan endpoint directly.
1002 if (mode !== 'inline') { 1003 // Mark the form so the normal submit handler skips Stripe redirect. 1004 form._ppPlanChange = true; 1005 1006 if (submit) { 1007 submit.textContent = 'Confirm switch to ' + b.level_name; 1008 1009 // Capture source_post_id before replacing the submit button. 1010 var hiddenPid = form.querySelector('input[name="source_post_id"]'); 1011 var sourcePid = hiddenPid ? (parseInt(hiddenPid.value, 10) || 0) : 0; 1012 1013 // Replace the submit button with a plain button to avoid form submit entirely. 1014 var confirmBtn = document.createElement('button'); 1015 confirmBtn.type = 'button'; 1016 confirmBtn.className = submit.className; 1017 confirmBtn.textContent = submit.textContent; 1018 submit.parentNode.replaceChild(confirmBtn, submit); 1019 1020 confirmBtn.addEventListener('click', function () { 1021 confirmBtn.disabled = true; 1022 if (box) { box.classList.remove('success', 'error'); box.textContent = 'Switching planâ¦'; } 1023 var couponInput = form.querySelector('input[name="coupon"]'); 1024 var payload = { level: levelSlug, coupon: couponInput ? (couponInput.value || '') : '' }; 1025 if (sourcePid) { payload.source_post_id = sourcePid; } 1026 postJSON('/public/account/change-plan', payload) 1027 .then(function (res2) { 1028 if (!res2.ok || !res2.body) { 1029 confirmBtn.disabled = false; 1030 if (box) { box.classList.add('error'); box.textContent = (res2.body && res2.body.message) || 'Could not switch plans.'; } 1031 return; 1032 } 1033 if (box) { box.classList.add('success'); box.textContent = 'Plan switched! Redirectingâ¦'; } 1034 setTimeout(function () { 1035 window.location = res2.body.redirect || window.location.href; 1036 }, 800); 1037 }).catch(function () { 1038 confirmBtn.disabled = false; 1039 if (box) { box.classList.add('error'); box.textContent = 'Network error.'; } 1040 }); 1041 }); 1042 } 1043 } 1044 // For inline mode, just show the banner - the normal inline checkout flow 1045 // will handle the upgrade automatically when the form is submitted. 1046 }).catch(function (err) { 1047 // Network error â retry transiently before giving up. 1048 if (retryIfTransient()) { return; } 1049 // Log error for debugging but don't show to user 1050 console.error('Plan change preview error:', err); 1051 }); 1052 } // end runPreview 1053 1054 // Expose runPreview so initCouponPricing can re-trigger the preview when a 1055 // coupon is applied or removed manually (i.e. not via a URL parameter). 1056 form._ppRunPreview = function () { 1057 form.removeAttribute('data-ppaccess-amount-locked'); 1058 runPreview(postalEl ? postalEl.value.trim() : ''); 1059 }; 1060 1061 runPreview(postalEl ? postalEl.value.trim() : ''); 1062 } 1063 1064 function escHtml(str) { 1065 return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); 1066 } 1067 1068 function initTaxEstimate(form) { 1069 var reviewPanel = form.querySelector('.ppaccess-review-panel'); 1070 if (!reviewPanel) { return null; } 1071 1072 function val(name) { 1073 var el = form.querySelector('[name="' + name + '"]'); 1074 return el ? el.value.trim() : ''; 1075 } 1076 1077 function upsertTaxRow(taxDisplay, totalDisplay) { 1078 var existing = reviewPanel.querySelector('[data-ppaccess-tax-row]'); 1079 if (taxDisplay) { 1080 if (!existing) { 1081 existing = document.createElement('div'); 1082 existing.setAttribute('data-ppaccess-tax-row', '1'); 1083 var amountRow = reviewPanel.querySelector('[data-ppaccess-amount-due]'); 1084 var insertBefore = amountRow ? amountRow.parentNode : null; 1085 if (insertBefore && insertBefore.parentNode === reviewPanel) { 1086 reviewPanel.insertBefore(existing, insertBefore); 1087 } else { 1088 reviewPanel.appendChild(existing); 1089 } 1090 } 1091 existing.innerHTML = '<span>Sales tax</span><strong>' + escHtml(taxDisplay) + '</strong>'; 1092 var amountEl = reviewPanel.querySelector('[data-ppaccess-amount-due]'); 1093 if (amountEl && totalDisplay) { amountEl.textContent = totalDisplay; } 1094 } else if (existing) { 1095 existing.remove(); 1096 } 1097 } 1098 1099 return function fetchTaxEstimate() { 1100 // Refresh the nonce before calling calculate-tax so guest sessions 1101 // with a stale page-baked nonce don't get a 403. 1102 var rest = String((cfg && cfg.rest) || '').replace(/\/$/, ''); 1103 fetch(rest + '/public/nonce', { credentials: 'same-origin' }) 1104 .then(function (r) { return r.json().catch(function () { return {}; }); }) 1105 .then(function (data) { if (data && data.nonce) { cfg.nonce = data.nonce; } }) 1106 .catch(function () {}) 1107 .then(function () { 1108 var cardPostalForTax = val('card_postal_code'); 1109 // Billing address fields â shipping address fields â nothing. 1110 var postal = cardPostalForTax || val('address_postal_code') || val('shipping_postal_code'); 1111 var country = val('address_country') || (cardPostalForTax ? 'US' : '') || val('shipping_country'); 1112 if (!postal && !country) { return; } 1113 return postJSON('/public/calculate-tax', { 1114 level_id: parseInt(form.getAttribute('data-level-id') || '0', 10), 1115 address_line1: val('address_line1'), 1116 address_city: val('address_city'), 1117 address_state: val('address_state'), 1118 address_postal_code: postal, 1119 address_country: country, 1120 shipping_line1: val('shipping_line1'), 1121 shipping_city: val('shipping_city'), 1122 shipping_state: val('shipping_state'), 1123 shipping_postal_code: val('shipping_postal_code'), 1124 shipping_country: val('shipping_country'), 1125 }, { skipToken: true }).then(function (res) { 1126 if (res.ok && res.body) { 1127 upsertTaxRow(res.body.tax_display || '', res.body.total_display || ''); 1128 } else { 1129 console.error('[ppaccess] Tax calculation failed:', res.body); 1130 } 1131 }).catch(function (err) { console.error('[ppaccess] Tax calculation error:', err); }); 1132 }); 1133 }; 1134 } 1135 1136 function initInlineSubscribe(form) { 1137 var box = form.querySelector('.ppaccess-form-result'); 1138 var submit = form.querySelector('button[type=submit]'); 1139 function setLoading(on) { 1140 if (!submit) { return; } 1141 submit.disabled = on; 1142 if (on) { 1143 submit.dataset.originalText = submit.textContent; 1144 submit.innerHTML = '<span class="ppaccess-btn-spinner" aria-hidden="true"></span>Processingâ¦'; 1145 submit.classList.add('ppaccess-button--loading'); 1146 } else { 1147 submit.classList.remove('ppaccess-button--loading'); 1148 if (submit.dataset.originalText) { 1149 submit.textContent = submit.dataset.originalText; 1150 delete submit.dataset.originalText; 1151 } 1152 } 1153 } 1154 var elementType = form.getAttribute('data-element-type') || 'card'; 1155 var usePaymentElement = elementType === 'payment'; 1156 var mountTarget = form.querySelector(usePaymentElement ? '#ppaccess-payment-element' : '#ppaccess-card-element'); 1157 var errorTarget = form.querySelector('#ppaccess-card-errors'); 1158 var layout = form.getAttribute('data-layout') || 'default'; 1159 var isStepped = layout === 'stepped'; 1160 var accountComplete = form.getAttribute('data-account-complete') === '1'; 1161 var cardComplete = false;
1162 1163 if (!mountTarget) { return; } 1164 // Shared object populated by the Address Element change event so that 1165 var fetchTaxEstimate = initTaxEstimate(form) || function () {}; 1166 1167 // Declare before bindSteppedCheckout() so setStep('payment') can set 1168 // paymentStepVisible = true without it being reset by a later var declaration. 1169 var paymentElementSetup = null; 1170 var confirmedPmId = null; // cached after a successful confirmSetup so retries don't re-confirm an already-succeeded SetupIntent 1171 var paymentElementMounted = false; 1172 var paymentStepVisible = false; 1173 1174 bindSteppedCheckout(); 1175 if (!cfg.publishable_key) { 1176 setStatus('Stripe is not configured.', true); 1177 return; 1178 } 1179 1180 // A site charging through the PaywallProject platform makes payments on 1181 // its own connected account, and Stripe.js has to be told so â without 1182 // it Elements builds a payment for the platform's account, which holds 1183 // none of this publisher's customers. 1184 var stripe = cfg.stripe_account 1185 ? Stripe(cfg.publishable_key, { stripeAccount: cfg.stripe_account }) 1186 : Stripe(cfg.publishable_key); 1187 1188 function mountPaymentElement() { 1189 if (paymentElementMounted || !paymentElementSetup || !paymentStepVisible) { return; } 1190 paymentElementMounted = true; 1191 mountTarget.innerHTML = ''; 1192 paymentElementSetup.paymentElement.mount(mountTarget); 1193 paymentElementSetup.paymentElement.on('change', function (event) { 1194 cardComplete = !!event.complete; 1195 if (errorTarget) { 1196 errorTarget.textContent = event.error ? event.error.message : ''; 1197 } 1198 }); 1199 } 1200 1201 if (usePaymentElement) { 1202 // Mark payment step as visible immediately for non-stepped layouts. 1203 if (!isStepped) { paymentStepVisible = true; } 1204 1205 postJSON('/public/payment-element-intent', { 1206 level_id: form.getAttribute('data-level-id') || '' 1207 }, { skipToken: true }).then(function (res) { 1208 if (!res.ok || !res.body || !res.body.client_secret) { 1209 setStatus('Could not initialise payment form.', true); 1210 return; 1211 } 1212 var elements = core.stripePaymentElement.createElements(stripe, res.body.client_secret); 1213 var pe = elements.create('payment'); 1214 paymentElementSetup = { 1215 elements: elements, 1216 paymentElement: pe, 1217 isComplete: function () { return cardComplete; } 1218 }; 1219 1220 // Mount immediately if the payment step is already visible 1221 // (non-stepped layout, or logged-in user who skipped the account step). 1222 mountPaymentElement(); 1223 }).catch(function () { 1224 setStatus('Could not initialise payment form.', true); 1225 }); 1226 } else { 1227 // Classic card element. 1228 var cardSetup = core.stripeCard.create(stripe, mountTarget, errorTarget); 1229 var card = cardSetup.card; 1230 card.on('change', function (event) { 1231 cardComplete = !!event.complete; 1232 }); 1233 } 1234 1235 function setStatus(message, isError) { 1236 if (!box) { return; } 1237 box.classList.remove('success', 'error'); 1238 if (isError) { box.classList.add('error'); } 1239 box.textContent = message; 1240 } 1241 1242 function val(name) { 1243 var els = form.querySelectorAll('[name="' + name + '"]'); 1244 for (var i = 0; i < els.length; i++) { 1245 if (!els[i].disabled) { return (els[i].value || '').trim(); } 1246 } 1247 return ''; 1248 } 1249 1250 function validateRequired(container) { 1251 var required = Array.prototype.slice.call((container || form).querySelectorAll('input[required], textarea[required], select[required]')); 1252 for (var i = 0; i < required.length; i++) { 1253 if (!required[i].checkValidity()) { 1254 required[i].reportValidity(); 1255 return false; 1256 } 1257 } 1258 return true; 1259 } 1260 1261 function stepSection(name) { 1262 return form.querySelector('[data-ppaccess-step="' + name + '"]'); 1263 } 1264 1265 function setStep(name) { 1266 if (!isStepped) { return; } 1267 var order = ['account', 'payment', 'billing', 'review']; 1268 var activeIndex = order.indexOf(name); 1269 var sections = form.querySelectorAll('[data-ppaccess-step]'); 1270 sections.forEach(function (section) { 1271 var stepName = section.getAttribute('data-ppaccess-step'); 1272 var stepIndex = order.indexOf(stepName); 1273 section.classList.toggle('is-active', stepName === name); 1274 section.classList.toggle('is-complete', stepIndex >= 0 && stepIndex < activeIndex); 1275 section.classList.toggle('is-locked', stepIndex > activeIndex); 1276 var marker = section.querySelector('.ppaccess-step-marker'); 1277 if (marker && stepIndex >= 0) { 1278 marker.textContent = stepIndex < activeIndex ? 'â' : String(stepIndex + 1); 1279 } 1280 }); 1281 // Mount Payment Element now that the payment step is visible in the DOM. 1282 // Defer one rAF so the browser has a chance to apply the is-active CSS 1283 // (display:none â display:block) before Stripe measures the container. 1284 if (name === 'payment' && usePaymentElement) { 1285 paymentStepVisible = true; 1286 requestAnimationFrame(function () { mountPaymentElement(); }); 1287 } 1288 } 1289 1290 function setAccountSummary(email) { 1291 var summary = form.querySelector('[data-ppaccess-account-summary]'); 1292 if (summary) { summary.textContent = email || 'Account ready.'; } 1293 } 1294 1295 function setAccountHint(message, isError) { 1296 var hint = form.querySelector('[data-ppaccess-account-hint]'); 1297 if (!hint) { return; } 1298 hint.classList.toggle('error', !!isError); 1299 hint.textContent = message; 1300 } 1301 1302 function lockAccount(email) { 1303 accountComplete = true; 1304 form.setAttribute('data-account-complete', '1'); 1305 setAccountSummary(email); 1306 var emailField = form.querySelector('[name="email"]'); 1307 if (emailField) { emailField.readOnly = true; } 1308 setStep('payment'); 1309 } 1310 1311 function bindSteppedCheckout() { 1312 if (!isStepped) { return; } 1313 1314 setStep(accountComplete ? 'payment' : 'account'); 1315 if (accountComplete) {
1316 setAccountSummary(val('email')); 1317 } 1318 1319 var emailField = form.querySelector('[name="email"]'); 1320 var passwordField = form.querySelector('[name="password"]'); 1321 var passwordWrap = form.querySelector('[data-ppaccess-password-field]'); 1322 var accountButton = form.querySelector('[data-ppaccess-account-continue]'); 1323 var forgotWrap = form.querySelector('[data-ppaccess-forgot]'); 1324 var forgotButton = form.querySelector('[data-ppaccess-passwordless]'); 1325 var accountMode = ''; 1326 1327 function showPasswordField(mode) { 1328 if (!passwordWrap || !passwordField) { return; } 1329 passwordWrap.hidden = false; 1330 passwordField.disabled = false; 1331 passwordField.setAttribute('autocomplete', mode === 'existing' ? 'current-password' : 'new-password'); 1332 // Offer the sign-in-link recovery only for existing accounts. 1333 if (forgotWrap) { forgotWrap.hidden = mode !== 'existing'; } 1334 } 1335 1336 function hidePasswordField() { 1337 if (!passwordWrap || !passwordField) { return; } 1338 passwordField.value = ''; 1339 passwordField.disabled = true; 1340 passwordWrap.hidden = true; 1341 if (forgotWrap) { forgotWrap.hidden = true; } 1342 } 1343 1344 if (forgotButton) { 1345 forgotButton.addEventListener('click', function () { 1346 var email = val('email'); 1347 if (!emailField || !emailField.checkValidity()) { 1348 if (emailField) { emailField.reportValidity(); } 1349 return; 1350 } 1351 forgotButton.disabled = true; 1352 setAccountHint('Sending your sign-in link...', false); 1353 // Bake the current checkout URL in so the emailed link returns 1354 // the user right back here, logged in, to finish paying. 1355 postJSON('/public/passwordless', { email: email, redirect: window.location.href }).then(function () { 1356 forgotButton.disabled = false; 1357 setAccountHint('Check your email for a sign-in link. Open it on this device and youâll come right back here to finish.', false); 1358 }).catch(function () { 1359 forgotButton.disabled = false; 1360 setAccountHint('Could not send the sign-in link. Please try again.', true); 1361 }); 1362 }); 1363 } 1364 1365 function checkEmail() { 1366 var email = val('email'); 1367 if (!emailField || !emailField.checkValidity()) { 1368 return Promise.resolve(false); 1369 } 1370 setAccountHint('Checking account...', false); 1371 return postJSON('/public/check-account-email', { email: email }).then(function (res) { 1372 if (!res.ok || !res.body) { 1373 setAccountHint((res.body && res.body.message) || 'Could not check that email.', true); 1374 return false; 1375 } 1376 accountMode = res.body.exists ? 'existing' : 'new'; 1377 showPasswordField(accountMode); 1378 setAccountHint( 1379 accountMode === 'existing' ? 'Enter your password to sign in.' : 'Create a password to finish your account.', 1380 false 1381 ); 1382 return true; 1383 }).catch(function () { 1384 setAccountHint('Network error while checking email.', true); 1385 return false; 1386 }); 1387 } 1388 1389 if (emailField && !accountComplete) { 1390 emailField.addEventListener('blur', checkEmail); 1391 emailField.addEventListener('input', function () { 1392 accountMode = ''; 1393 hidePasswordField(); 1394 setAccountHint('We will check whether this email already has an account.', false); 1395 }); 1396 } 1397 1398 if (accountButton) { 1399 accountButton.addEventListener('click', function () { 1400 var account = stepSection('account'); 1401 if (!accountMode) { 1402 if (!emailField || !emailField.checkValidity()) { 1403 if (emailField) { emailField.reportValidity(); } 1404 return; 1405 } 1406 accountButton.disabled = true; 1407 checkEmail().then(function (ok) { 1408 accountButton.disabled = false; 1409 if (ok && passwordField) { passwordField.focus(); } 1410 }); 1411 return; 1412 } 1413 if (!validateRequired(account)) { return; } 1414 // For new accounts, just validate and move forward. 1415 // Account will be created on payment submission (like default checkout). 1416 if (accountMode === 'existing') { 1417 // Existing user: verify password via API 1418 accountButton.disabled = true; 1419 setAccountHint('Signing in...', false); 1420 postJSON('/public/checkout-account', { 1421 email: val('email'), 1422 password: val('password') 1423 }).then(function (res) { 1424 accountButton.disabled = false;
1425 if (!res.ok || !res.body) { 1426 setAccountHint((res.body && res.body.message) || 'Could not continue.', true); 1427 return; 1428 } 1429 if (res.body.nonce) { cfg.nonce = res.body.nonce; } 1430 if (res.body.checkout_token) { cfg.checkoutToken = res.body.checkout_token; } 1431 lockAccount(res.body.email || val('email')); 1432 setStatus('', false); 1433 if (res.body.has_recurring_sub && levelIsRecurring && !levelHasTrial) { 1434 initPlanChangePreview(form, levelSlug, mode); 1435 } 1436 }).catch(function () { 1437 accountButton.disabled = false; 1438 setAccountHint('Network error.', true); 1439 }); 1440 } else { 1441 // New account: just validate and proceed, account created on payment 1442 lockAccount(val('email')); 1443 setStatus('', false); 1444 } 1445 }); 1446 } 1447 1448 form.querySelectorAll('[data-ppaccess-edit-step]').forEach(function (button) { 1449 button.addEventListener('click', function () { 1450 var emailField = form.querySelector('[name="email"]'); 1451 if (emailField) { emailField.readOnly = false; } 1452 accountComplete = false; 1453 form.setAttribute('data-account-complete', '0'); 1454 setStep(button.getAttribute('data-ppaccess-edit-step') || 'account'); 1455 }); 1456 }); 1457 1458 form.querySelectorAll('[data-ppaccess-next-step]').forEach(function (button) { 1459 button.addEventListener('click', function () { 1460 var next = button.getAttribute('data-ppaccess-next-step') || ''; 1461 if (next === 'billing' && !cardComplete) { 1462 setStatus('Please enter your card details.', true); 1463 return; 1464 } 1465 if (next === 'review' && !validateRequired(stepSection('billing'))) { 1466 return; 1467 } 1468 setStatus('', false); 1469 setStep(next); 1470 if (next === 'review' && form.getAttribute('data-tax-enabled') === '1') { 1471 fetchTaxEstimate(); 1472 } 1473 }); 1474 }); 1475 } 1476 1477 form.addEventListener('submit', function (event) { 1478 event.preventDefault(); 1479 if (isStepped && !accountComplete) { 1480 setStatus('Please complete the account step first.', true); 1481 setStep('account'); 1482 return; 1483 } 1484 if (!cardComplete) { 1485 setStatus('Please enter your card details.', true); 1486 if (isStepped) { setStep('payment'); } 1487 return; 1488 } 1489 if (!checkTosAccepted(form)) { 1490 setStatus('Please agree to the terms above before continuing.', true); 1491 return; 1492 } 1493 1494 var firstName = val('first_name'); 1495 var lastName = val('last_name'); 1496 var email = val('email'); 1497 if (!email) { 1498 setStatus('Email is required.', true); 1499 if (isStepped) { setStep('account'); } 1500 return; 1501 } 1502 if (isStepped && !validateRequired(stepSection('billing'))) { 1503 setStep('billing'); 1504 return; 1505 } 1506 if (!isStepped && !validateRequired(form)) { 1507 return; 1508 } 1509 1510 setLoading(true); 1511 setStatus('', false); 1512 1513 var payerName = (firstName + ' ' + lastName).trim(); 1514 var billingDetails = { email: email }; 1515 if (payerName) { billingDetails.name = payerName; } 1516 if (val('phone')) { billingDetails.phone = val('phone'); } 1517 var addressLine1 = val('address_line1'); 1518 var cardPostal = val('card_postal_code'); 1519 var addressPostal = val('address_postal_code'); 1520 var addressCountry = val('address_country'); 1521 if (addressLine1 || cardPostal || addressPostal || addressCountry) { 1522 billingDetails.address = { 1523 line1: addressLine1 || undefined, 1524 line2: val('address_line2') || undefined, 1525 city: val('address_city') || undefined, 1526 state: val('address_state') || undefined, 1527 postal_code: cardPostal || addressPostal || undefined, 1528 country: addressCountry || undefined 1529 }; 1530 } 1531 1532 // Shared body sent to create-subscription-intent after we have a payment_method id. 1533 function buildSubscribeBody(paymentMethodId) { 1534 return { 1535 level: form.getAttribute('data-level') || '', 1536 payment_method: paymentMethodId, 1537 email: email, 1538 first_name: firstName, 1539 last_name: lastName, 1540 phone: val('phone'), 1541 company: val('company'), 1542 password: val('password'), 1543 address_line1: val('address_line1'), 1544 address_line2: val('address_line2'), 1545 address_city: val('address_city'), 1546 address_state: val('address_state'), 1547 address_postal_code: val('address_postal_code'), 1548 address_country: val('address_country'), 1549 shipping_line1: val('shipping_line1'), 1550 shipping_line2: val('shipping_line2'), 1551 shipping_city: val('shipping_city'), 1552 shipping_state: val('shipping_state'), 1553 shipping_postal_code: val('shipping_postal_code'), 1554 shipping_country: val('shipping_country'), 1555 card_postal_code: val('card_postal_code'), 1556 coupon: val('coupon'), 1557 custom_amount: val('custom_amount'), 1558 source_post_id: val('source_post_id'), 1559 redirect_to: window.location.href 1560 }; 1561 } 1562 1563 // Shared: send the body to the server, handle 3DS / finalize redirect. 1564 function submitToServer(paymentMethodId) { 1565 var body = buildSubscribeBody(paymentMethodId); 1566 return addCaptcha(form, body, 'ppaccess_inline_subscribe').then(function (bodyWithCaptcha) { 1567 return postJSON('/public/create-subscription-intent', bodyWithCaptcha); 1568 }).then(function (res) { 1569 if (!res.ok || !res.body) { 1570 setStatus((res.body && res.body.message) || 'Could not start subscription.', true);
1571 setLoading(false); 1572 return; 1573 } 1574 if (res.body.status === 'succeeded') { 1575 setStatus('Subscribed.', false); 1576 var dest = res.body.redirect || cfg.return_url || window.location.href; 1577 window.location = addLoginToken(dest, res.body.login_token); 1578 return; 1579 } 1580 if (res.body.client_secret) { 1581 var fn = res.body.intent_type === 'setup_intent' ? 'confirmCardSetup' : 'confirmCardPayment'; 1582 return stripe[fn](res.body.client_secret, { payment_method: paymentMethodId }) 1583 .then(function (confirmResult) { 1584 if (confirmResult.error) { 1585 setStatus(confirmResult.error.message || 'Payment failed.', true); 1586 setLoading(false); 1587 return; 1588 } 1589 return postJSON('/public/finalize-subscription', { subscription_id: res.body.subscription_id }) 1590 .then(function (fin) { 1591 setStatus('Subscribed.', false); 1592 window.location = addLoginToken(cfg.return_url || window.location.href, fin && fin.body && fin.body.login_token); 1593 }); 1594 }); 1595 } 1596 }); 1597 } 1598 1599 var submitPromise; 1600 1601 if (usePaymentElement && paymentElementSetup) { 1602 // Payment Element: confirm the SetupIntent client-side to save the 1603 // payment method (no charge here), then hand the payment_method id to 1604 // our existing create-subscription-intent endpoint which handles the 1605 // actual charge / subscription creation identically to the card element. 1606 // If confirmSetup already succeeded on a prior attempt (e.g. server returned 1607 // an error after the card was saved), reuse the cached pmId instead of 1608 // calling confirmSetup again on the already-succeeded SetupIntent. 1609 var confirmPromise; 1610 if (confirmedPmId) { 1611 confirmPromise = Promise.resolve(confirmedPmId); 1612 } else { 1613 confirmPromise = stripe.confirmSetup({ 1614 elements: paymentElementSetup.elements, 1615 confirmParams: { 1616 return_url: window.location.href, 1617 payment_method_data: { billing_details: billingDetails } 1618 }, 1619 redirect: 'if_required' 1620 }).then(function (result) { 1621 if (result.error) { 1622 setStatus(result.error.message || 'Payment failed.', true); 1623 setLoading(false); 1624 return null; 1625 } 1626 var intent = result.setupIntent; 1627 var pmId = typeof intent.payment_method === 'string' 1628 ? intent.payment_method 1629 : (intent.payment_method && intent.payment_method.id) || ''; 1630 if (!pmId) { 1631 setStatus('Could not retrieve payment method.', true); 1632 setLoading(false); 1633 return null; 1634 } 1635 confirmedPmId = pmId; 1636 return pmId; 1637 }); 1638 } 1639 submitPromise = confirmPromise.then(function (pmId) { 1640 if (!pmId) { return; } 1641 return submitToServer(pmId); 1642 }); 1643 } else { 1644 // Classic card element: create payment method then submit. 1645 submitPromise = stripe.createPaymentMethod({ 1646 type: 'card', 1647 card: card, 1648 billing_details: billingDetails 1649 }).then(function (pmResult) { 1650 if (pmResult.error) { 1651 setStatus(pmResult.error.message || 'Card was declined.', true); 1652 setLoading(false); 1653 return; 1654 } 1655 return submitToServer(pmResult.paymentMethod.id); 1656 }); 1657 } 1658 1659 submitPromise.catch(function (err) { 1660 setStatus((err && err.message) || 'Network error.', true); 1661 setLoading(false); 1662 }); 1663 }); 1664 } 1665 1666 // Free-for-your-organization offer on the paid checkout. 1667 // 1668 // Nothing here runs until an address is typed, and nothing is shown unless 1669 // the server says that address qualifies. A buyer whose domain is not 1670 // listed sees the checkout exactly as it was â no extra step, no notice, 1671 // no change of any kind. 1672 var subscribeForm = document.getElementById('ppaccess-subscribe-form'); 1673 if (subscribeForm && 1674 subscribeForm.getAttribute('data-domain-offer') === '1' && 1675 subscribeForm.getAttribute('data-logged-in') !== '1') { 1676 var domainEmail = subscribeForm.querySelector('[name="email"]'); 1677 var domainLevel = subscribeForm.getAttribute('data-level') || ''; 1678 var domainPanel = null; 1679 var domainAsked = ''; 1680 // The address the visible offer belongs to. The offer sends a code to 1681 // this address and claims the level for it, so it must never outlive 1682 // the address still being in the email field. 1683 var domainOfferEmail = ''; 1684 var domainHidden = []; 1685 1686 // Someone who is not paying should not be asked to build a paid 1687 // account. Showing the offer puts the password, its hint and the 1688 // Continue button away, and choosing to pay brings them all back. 1689 var paidAccountFields = function () { 1690 var password = subscribeForm.querySelector('#ppaccess-password'); 1691 return [ 1692 subscribeForm.querySelector('[data-ppaccess-password-field]') || 1693 (password ? password.closest('.ppaccess-form-field') : null),
1694 subscribeForm.querySelector('[data-ppaccess-account-hint]'), 1695 subscribeForm.querySelector('[data-ppaccess-account-continue]'), 1696 subscribeForm.querySelector('button[type=submit]') 1697 ]; 1698 }; 1699 1700 var domainObserver = null; 1701 1702 var hidePaidAccountFields = function () { 1703 var password = subscribeForm.querySelector('#ppaccess-password'); 1704 // Disabled as well as hidden: a hidden required field would block 1705 // submission, and nothing typed in it should be posted. 1706 if (password) { 1707 domainHidden.push({ el: password, disabled: password.disabled }); 1708 password.disabled = true; 1709 } 1710 paidAccountFields().forEach(function (el) { 1711 if (el && !el.hidden) { 1712 domainHidden.push({ el: el, hidden: false }); 1713 el.hidden = true; 1714 } 1715 }); 1716 1717 // The checkout runs its own account lookup on the same blur, and 1718 // reveals the password field when it answers. That answer can land 1719 // after this one, so keep the paid fields down for as long as the 1720 // offer is on screen rather than hiding them once and hoping. 1721 if (domainObserver || typeof MutationObserver === 'undefined') { return; } 1722 domainObserver = new MutationObserver(function () { 1723 if (!domainPanel) { return; } 1724 var pw = subscribeForm.querySelector('#ppaccess-password'); 1725 if (pw && !pw.disabled) { pw.disabled = true; } 1726 paidAccountFields().forEach(function (el) { 1727 if (el && !el.hidden) { el.hidden = true; } 1728 }); 1729 }); 1730 domainObserver.observe(subscribeForm, { 1731 attributes: true, 1732 subtree: true, 1733 attributeFilter: ['hidden', 'disabled'] 1734 }); 1735 }; 1736 1737 var restorePaidAccountFields = function () { 1738 if (domainObserver) { 1739 domainObserver.disconnect(); 1740 domainObserver = null; 1741 } 1742 domainHidden.forEach(function (entry) { 1743 if (Object.prototype.hasOwnProperty.call(entry, 'disabled')) { 1744 entry.el.disabled = entry.disabled; 1745 } else { 1746 entry.el.hidden = entry.hidden; 1747 } 1748 }); 1749 domainHidden = []; 1750 }; 1751 1752 // Take the offer down and hand the checkout back exactly as it was. 1753 var closeDomainOffer = function () { 1754 if (!domainPanel) { return; } 1755 domainPanel.remove(); 1756 domainPanel = null; 1757 domainOfferEmail = ''; 1758 restorePaidAccountFields(); 1759 }; 1760 1761 var showDomainOffer = function (email) { 1762 if (domainPanel) { return; } 1763 domainOfferEmail = email; 1764 domainPanel = document.createElement('div'); 1765 domainPanel.className = 'ppaccess-domain-offer'; 1766 // The size is set here rather than left to the theme, which styles 1767 // checkout paragraphs large enough that this notice shouted. 1768 domainPanel.style.cssText = 1769 'margin:12px 0;padding:12px;border:1px solid #cbd5e1;border-radius:6px;' + 1770 'font-size:14px;line-height:1.5'; 1771 domainPanel.innerHTML = 1772 '<p style="margin:0 0 4px;font-size:15px;font-weight:600">' + 1773 'You\'re eligible for free access</p>' + 1774 // Says nothing about where the address is from: it may be a 1775 // school, an employer, a membership or anything else the 1776 // publisher has an arrangement with. 1777 '<p style="margin:0 0 8px;font-size:14px" data-ppaccess-domain-intro>' + 1778 'This email address qualifies for this plan at no cost. ' + 1779 'To check that the address is yours, we\'ll send it a 6-digit code.</p>' + 1780 '<button type="button" class="ppaccess-button" data-ppaccess-domain-start>' + 1781 'Send the code to this email</button>' + 1782 '<div class="ppaccess-domain-result" role="status" aria-live="polite" style="margin-top:8px;font-size:14px"></div>' + 1783 '<p style="margin:8px 0 0;font-size:13px">' + 1784 '<button type="button" class="ppaccess-link-button" data-ppaccess-domain-pay>' + 1785 'No thanks, I will pay for this plan</button></p>'; 1786 domainEmail.parentNode.insertBefore(domainPanel, domainEmail.nextSibling); 1787 hidePaidAccountFields(); 1788 1789 var result = domainPanel.querySelector('.ppaccess-domain-result'); 1790 var intro = domainPanel.querySelector('[data-ppaccess-domain-intro]'); 1791 var say = function (message) { if (result) { result.textContent = message; } }; 1792 1793 domainPanel.addEventListener('click', function (event) { 1794 var startBtn = event.target.closest('[data-ppaccess-domain-start]'); 1795 var claimBtn = event.target.closest('[data-ppaccess-domain-claim]'); 1796 var payBtn = event.target.closest('[data-ppaccess-domain-pay]'); 1797 1798 // Back to the ordinary checkout, exactly as it was. The blur is 1799 // re-fired so the checkout's own account lookup decides what the 1800 // account step should show, rather than this code guessing. 1801 if (payBtn) { 1802 closeDomainOffer(); 1803 if (domainEmail) { domainEmail.dispatchEvent(new Event('blur')); } 1804 return; 1805 } 1806 1807 if (startBtn) { 1808 startBtn.disabled = true; 1809 say('Sending a codeâ¦'); 1810 postJSON('/public/free-registration/request-otp', { email: email }) 1811 .then(function (res) { 1812 if (!res.ok) { 1813 startBtn.disabled = false;
1814 say((res.body && res.body.message) || 'Could not send a code.'); 1815 return; 1816 } 1817 startBtn.remove(); 1818 if (intro) { 1819 intro.textContent = 'We sent a 6-digit code to ' + email + 1820 '. Enter it below and your free access starts right away.'; 1821 } 1822 var box = document.createElement('div'); 1823 box.innerHTML = 1824 '<label for="ppaccess-domain-code" style="font-size:14px">Enter your code</label> ' + 1825 '<input id="ppaccess-domain-code" type="text" inputmode="numeric" pattern="[0-9]{6}" ' + 1826 'maxlength="6" placeholder="123456" autocomplete="one-time-code"> ' + 1827 '<button type="button" class="ppaccess-button" data-ppaccess-domain-claim>Activate free access</button>'; 1828 domainPanel.insertBefore(box, result); 1829 say(''); 1830 }) 1831 .catch(function () { startBtn.disabled = false; say('Network error.'); }); 1832 return; 1833 } 1834 1835 if (claimBtn) { 1836 var codeField = domainPanel.querySelector('#ppaccess-domain-code'); 1837 var code = ((codeField && codeField.value) || '').replace(/\D/g, ''); 1838 if (code.length !== 6) { say('Please enter the 6-digit code.'); return; } 1839 claimBtn.disabled = true; 1840 say('Confirmingâ¦'); 1841 postJSON('/public/free-registration/verify-otp', { email: email, code: code }) 1842 .then(function (res) { 1843 if (!res.ok) { 1844 claimBtn.disabled = false; 1845 say((res.body && res.body.message) || 'Incorrect code.'); 1846 return; 1847 } 1848 var body = { level: domainLevel, email: email }; 1849 ['first_name', 'last_name', 'phone', 'company'].forEach(function (name) { 1850 var field = subscribeForm.querySelector('[name="' + name + '"]'); 1851 if (field && field.value) { body[name] = field.value; } 1852 }); 1853 return addCaptcha(subscribeForm, body, 'ppaccess_claim_free') 1854 .then(function (withCaptcha) { return postJSON('/public/claim-free', withCaptcha); }) 1855 .then(function (claimRes) { 1856 if (!claimRes.ok || !claimRes.body || claimRes.body.status !== 'succeeded') { 1857 claimBtn.disabled = false; 1858 say((claimRes.body && claimRes.body.message) || 'Could not activate.'); 1859 return; 1860 } 1861 say('Done. Redirectingâ¦'); 1862 window.location = claimRes.body.redirect || window.location.href; 1863 }); 1864 }) 1865 .catch(function () { claimBtn.disabled = false; say('Network error.'); }); 1866 } 1867 }); 1868 }; 1869 1870 var checkDomainOffer = function () { 1871 var email = (domainEmail.value || '').trim(); 1872 1873 // An offer belongs to the address it was raised for. The moment the 1874 // field holds a different address the old offer is wrong â it would 1875 // mail the code to the previous address and create the account 1876 // there â so it comes down before anything else, whether or not the 1877 // new address turns out to qualify. Leaving it up on a "no" would 1878 // also strand the buyer: the password and Continue stay hidden. 1879 if (domainPanel && email !== domainOfferEmail) { closeDomainOffer(); } 1880 1881 if (!email || email === domainAsked || !domainEmail.checkValidity()) { return; } 1882 domainAsked = email; 1883 postJSON('/public/domain-eligibility', { level: domainLevel, email: email }) 1884 .then(function (res) { 1885 // A slow answer for an address the buyer has since replaced 1886 // must not raise an offer for it. 1887 if (email !== domainAsked) { return; } 1888 if (res.ok && res.body && res.body.eligible) { showDomainOffer(email); } 1889 }) 1890 .catch(function () { /* Offer simply does not appear. */ }); 1891 }; 1892 1893 if (domainEmail) { 1894 domainEmail.addEventListener('blur', checkDomainOffer); 1895 domainEmail.addEventListener('change', checkDomainOffer); 1896 } 1897 } 1898 1899 var freeForm = document.getElementById('ppaccess-claim-free'); 1900 if (freeForm) { 1901 freeForm.addEventListener('submit', function (event) { 1902 event.preventDefault(); 1903 var box = freeForm.querySelector('.ppaccess-form-result'); 1904 var submit = freeForm.querySelector('button[type=submit]'); 1905 function setStatus(message, isError) { 1906 if (!box) { return; } 1907 box.classList.remove('success', 'error'); 1908 if (isError) { box.classList.add('error'); } 1909 box.textContent = message; 1910 } 1911 var data = new FormData(freeForm); 1912 var body = { level: freeForm.getAttribute('data-level') || '' };
1913 // Send every field the form rendered (email, name, phone, company, 1914 // billing + shipping address) so the level's configured fields all 1915 // reach the claim endpoint. 1916 data.forEach(function (v, k) { 1917 if (v !== '' && v !== null) { body[k] = v; } 1918 }); 1919 // Don't send the current (subscribe) page as the redirect â the 1920 // server decides where to land (the source article, or home), so 1921 // the user isn't bounced back onto the subscribe form. 1922 if (submit) { submit.disabled = true; } 1923 setStatus('Activatingâ¦', false); 1924 addCaptcha(freeForm, body, 'ppaccess_claim_free').then(function (bodyWithCaptcha) { 1925 return postJSON('/public/claim-free', bodyWithCaptcha); 1926 }).then(function (res) { 1927 if (!res.ok || !res.body || res.body.status !== 'succeeded') { 1928 setStatus((res.body && res.body.message) || 'Could not activate.', true); 1929 if (submit) { submit.disabled = false; } 1930 return; 1931 } 1932 setStatus('Done. Redirectingâ¦', false); 1933 window.location = res.body.redirect || window.location.href; 1934 }).catch(function () { 1935 setStatus('Network error.', true); 1936 if (submit) { submit.disabled = false; } 1937 }); 1938 }); 1939 } 1940 1941 // Re-fetch a fresh nonce immediately before any account action so that 1942 // cached pages (where the baked-in nonce may be stale or belong to a 1943 // different session) never trigger rest_cookie_invalid_nonce. This is 1944 // the same root problem that was solved for checkout via checkout_token; 1945 // for account actions (which require check_logged_in_with_nonce) we must 1946 // ensure the nonce matches the current logged-in session. 1947 function refreshNonceForAccountAction() { 1948 return fetch( 1949 (cfg.rest || '').replace(/\/$/, '') + '/public/nonce', 1950 { method: 'GET', credentials: 'same-origin' } 1951 ).then(function (r) { 1952 return r.ok ? r.json() : null; 1953 }).then(function (data) { 1954 if (data && data.nonce) { cfg.nonce = data.nonce; } 1955 if (data && data.account_token) { accountToken = data.account_token; } 1956 }).catch(function () { /* keep existing token/nonce as fallback */ }); 1957 } 1958 1959 function setBtnLoading(button, on, loadingText) { 1960 if (!button) { return; } 1961 button.disabled = on; 1962 if (on) { 1963 button.dataset.originalHtml = button.innerHTML; 1964 button.innerHTML = '<span class="ppaccess-btn-spinner" aria-hidden="true"></span>' + (loadingText || 'Processingâ¦'); 1965 button.classList.add('ppaccess-button--loading'); 1966 } else { 1967 button.classList.remove('ppaccess-button--loading'); 1968 if (button.dataset.originalHtml) { 1969 button.innerHTML = button.dataset.originalHtml; 1970 delete button.dataset.originalHtml; 1971 } 1972 } 1973 } 1974 1975 function openCancelModal(triggerBtn) { 1976 var accountEl = triggerBtn.closest('.ppaccess-account'); 1977 var modal = accountEl ? accountEl.querySelector('[data-ppaccess-modal="cancel"]') : null; 1978 if (!modal) { return; } 1979 modal.hidden = false; 1980 document.body.style.overflow = 'hidden'; 1981 var focusBtn = modal.querySelector('.ppaccess-button--danger') || modal.querySelector('[data-ppaccess-modal-close]'); 1982 if (focusBtn) { focusBtn.focus(); } 1983 } 1984 1985 function closeAccountModals() { 1986 document.querySelectorAll('[data-ppaccess-modal]').forEach(function (m) { m.hidden = true; }); 1987 document.body.style.overflow = ''; 1988 } 1989 1990 document.querySelectorAll('[data-ppaccess-account-action]').forEach(function (btn) { 1991 btn.addEventListener('click', function (e) { 1992 var action = btn.getAttribute('data-ppaccess-account-action'); 1993 1994 // Prevent anchor default navigation. 1995 if (btn.tagName === 'A') { e.preventDefault(); } 1996 1997 // Copying the feed URL is purely client-side â no REST round-trip. 1998 if (action === 'copy-rss-feed') { 1999 var feedInput = document.getElementById('ppaccess-rss-feed-url'); 2000 if (feedInput && navigator.clipboard) { 2001 navigator.clipboard.writeText(feedInput.value).then(function () { 2002 var original = btn.textContent; 2003 btn.textContent = 'Copied!'; 2004 setTimeout(function () { btn.textContent = original; }, 1500); 2005 }); 2006 } 2007 return; 2008 } 2009 2010 var paths = { 2011 cancel: '/public/account/cancel', 2012 resume: '/public/account/resume', 2013 renew: '/public/account/resume', 2014 'update-card': '/public/account/update-payment-method', 2015 'update-profile': '/public/account/update-profile', 2016 'regenerate-rss-feed': '/public/account/rss-feed/regenerate' 2017 }; 2018 if (!paths[action]) { return; } 2019 2020 // Cancel requires explicit confirmation through the modal dialog. 2021 if (action === 'cancel') { 2022 if (btn.getAttribute('data-ppaccess-confirmed') !== '1') { 2023 openCancelModal(btn); 2024 return; 2025 } 2026 setBtnLoading(btn, true, 'Cancellingâ¦'); 2027 } 2028 2029 // Update-profile: collect fields from the account form. 2030 var payload = {}; 2031 if (action === 'update-card') { 2032 payload = { return_url: window.location.href }; 2033 } else if (action === 'update-profile') { 2034 var accountEl = btn.closest('.ppaccess-account'); 2035 ['first_name', 'last_name', 'email', 'new_password', 'confirm_password'].forEach(function (field) { 2036 var input = accountEl ? accountEl.querySelector('[name="' + field + '"]') : null; 2037 if (input) { payload[field] = input.value; } 2038 }); 2039 } 2040 2041 // Profile saves show their progress on the button itself. 2042 if (action === 'update-profile') { 2043 setBtnLoading(btn, true, 'Savingâ¦'); 2044 }
2045 if (action === 'regenerate-rss-feed') { 2046 setBtnLoading(btn, true, 'Regeneratingâ¦'); 2047 } 2048 2049 // Always fetch a fresh token before firing the account action so that 2050 // stale page-load nonces (e.g. from a CDN cache) don't cause a 403. 2051 refreshNonceForAccountAction().then(function () { 2052 return postJSONWithAccountToken(paths[action], payload); 2053 }).then(function (res) { 2054 var accountEl = btn.closest('.ppaccess-account'); 2055 var box = accountEl ? accountEl.querySelector('.ppaccess-form-result') : null; 2056 2057 if (res.ok && res.body && res.body.url) { 2058 if (box) { 2059 box.textContent = 'Redirectingâ¦'; 2060 box.classList.remove('success', 'error'); 2061 box.classList.add('success'); 2062 } 2063 window.location = res.body.url; 2064 return; 2065 } 2066 if (res.ok) { 2067 if (action === 'resume' || action === 'renew' || action === 'cancel') { 2068 window.location.reload(); 2069 return; 2070 } 2071 if (action === 'update-profile') { 2072 // Clear the password fields after a successful save. 2073 ['new_password', 'confirm_password'].forEach(function (field) { 2074 var input = accountEl ? accountEl.querySelector('[name="' + field + '"]') : null; 2075 if (input) { input.value = ''; } 2076 }); 2077 // Confirm on the button itself, then revert after a moment. 2078 if (box) { box.textContent = ''; box.classList.remove('success', 'error'); } 2079 btn.classList.remove('ppaccess-button--loading'); 2080 btn.classList.add('ppaccess-button--done'); 2081 btn.innerHTML = 'Done.'; 2082 setTimeout(function () { 2083 btn.classList.remove('ppaccess-button--done'); 2084 setBtnLoading(btn, false); 2085 }, 1500); 2086 return; 2087 } 2088 if (action === 'regenerate-rss-feed') { 2089 var feedInput = document.getElementById('ppaccess-rss-feed-url'); 2090 if (feedInput && res.body && res.body.feed_url) { feedInput.value = res.body.feed_url; } 2091 btn.classList.remove('ppaccess-button--loading'); 2092 btn.classList.add('ppaccess-button--done'); 2093 btn.innerHTML = 'Done.'; 2094 setTimeout(function () { 2095 btn.classList.remove('ppaccess-button--done'); 2096 setBtnLoading(btn, false); 2097 }, 1500); 2098 return; 2099 } 2100 if (box) { 2101 box.classList.remove('error'); 2102 box.classList.add('success'); 2103 box.textContent = 'Saved.'; 2104 } 2105 return; 2106 } 2107 2108 // Failure: close any open modal, restore the button, surface the message. 2109 closeAccountModals(); 2110 setBtnLoading(btn, false); 2111 if (box) { 2112 box.textContent = (res.body && res.body.message) || 'Failed.'; 2113 box.classList.remove('success'); 2114 box.classList.add('error'); 2115 } 2116 }).catch(function () { 2117 closeAccountModals(); 2118 setBtnLoading(btn, false); 2119 var accountEl = btn.closest('.ppaccess-account'); 2120 var box = accountEl ? accountEl.querySelector('.ppaccess-form-result') : null; 2121 if (box) { 2122 box.textContent = 'Network error. Please try again.'; 2123 box.classList.remove('success'); 2124 box.classList.add('error'); 2125 } 2126 }); 2127 }); 2128 }); 2129 2130 document.querySelectorAll('[data-ppaccess-modal-close]').forEach(function (el) { 2131 el.addEventListener('click', function () { closeAccountModals(); }); 2132 }); 2133 2134 document.addEventListener('keydown', function (e) { 2135 if (e.key === 'Escape') { closeAccountModals(); } 2136 }); 2137})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.