PageSourceSearch

https://www.lebanonlocalnews.com/wp-content/plugins/paywall-project/assets/frontend/js/paywall.js?ver=3.1.0

js lebanonlocalnews.com collected 2026-10-02 11:53:47 UTC 89,022 bytes, 2,137 lines download raw bytes

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 &amp; 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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.