1/** 2 * NK Paywall â Alpine.js frontend. 3 * 4 * @package NKPaywall 5 */ 6 7/** 8 * Matomo event tracking helper. 9 */ 10const nkTrack = (category, action, name = '', value = 0) => { 11 if (typeof _paq !== 'undefined') { 12 _paq.push(['trackEvent', category, action, name, value]); 13 } 14}; 15 16/** 17 * Shared email validation (signup, signin, etc.). 18 * 19 * @param {string} email Raw email string. 20 * @return {boolean} Whether the string looks like a valid email. 21 */ 22function nkPaywallValidateEmail(email) { 23 return /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( 24 String(email || ''), 25 ); 26} 27 28/** Once per page: avoid double AJAX when Alpine inits the component more than once. */ 29let nkPaywallCheckDone = false; 30 31/** Once per page: the gate component must wire up (watchers, close handlers, URL 32 * popup dispatch) on a single canonical instance even if Alpine inits it twice. */ 33let nkPaywallGateInited = false; 34 35/** 36 * Cloudflare Turnstile invokes this global as soon as its (deferred) api.js loads. 37 * It MUST exist before that script runs, so it is defined here at module top level 38 * (this file is enqueued in the head with no defer, so it executes during HTML parse, 39 * before any deferred script). Defining it inside registerNkPaywallAlpineComponents() 40 * is too late: that runs on `alpine:init`, which only fires on DOMContentLoaded â 41 * after every deferred script (including Turnstile's api.js) has already run, so the 42 * callback would be undefined when Turnstile looks for it and it would time out. 43 */ 44window.__nkPaywallTurnstileLoaded = false; 45window.turnstileLoaded = function () { 46 window.__nkPaywallTurnstileLoaded = true; 47 if (typeof window.nkPaywallInitPopupTurnstile === 'function') { 48 window.nkPaywallInitPopupTurnstile(); 49 } 50}; 51 52function registerNkPaywallAlpineComponents() { 53 if (typeof Alpine === 'undefined' || window.__nkPaywallAlpineRegistered) { 54 return; 55 } 56 57 window.__nkPaywallAlpineRegistered = true; 58 59 // Single ref so only one listener handles show-popup (avoids duplicate AJAX when gate inits multiple times). 60 let gateInstance = null; 61 62 const turnstileForms = [ 63 { 64 formId: 'signin_form', 65 containerId: 'signin_turnstile', 66 }, 67 { 68 formId: 'lostpasswordform', 69 containerId: 'forget_turnstile', 70 }, 71 { 72 formId: 'pro_inquiry_form', 73 containerId: 'pro_inquiry_turnstile', 74 onVerified(form) { 75 form.dispatchEvent( 76 new CustomEvent('nk-paywall-nkpro-pro-inquiry', { 77 bubbles: true, 78 }), 79 ); 80 }, 81 }, 82 { 83 formId: 'fellowship_submit_form', 84 containerId: 'fellowship_turnstile', 85 onVerified(form) { 86 form.dispatchEvent( 87 new CustomEvent('nk-paywall-nkpro-fellowship', { 88 bubbles: true, 89 }), 90 ); 91 }, 92 }, 93 ]; 94 95 const TURNSTILE_UNAVAILABLE_MSG = 96 'Security check is still loading. Please wait a moment and try again.'; 97 98 const turnstileWidgets = new WeakMap(); 99 const turnstilePending = new WeakMap(); 100 101 function isTurnstileEnabled() { 102 return ( 103 typeof nkPaywall !== 'undefined' && 104 !!nkPaywall.turnstileEnabled && 105 !!nkPaywall.turnstileSiteKey 106 ); 107 } 108 109 function nativeSubmit(form) { 110 form.dataset.nkPaywallTurnstileBypass = '1'; 111 HTMLFormElement.prototype.submit.call(form); 112 } 113 114 function setSubmitButtonsDisabled(form, disabled) { 115 if (!form) { 116 return; 117 } 118 const buttons = form.querySelectorAll( 119 'button[type="submit"], input[type="submit"]', 120 ); 121 for (let i = 0; i < buttons.length; i++) { 122 buttons[i].disabled = disabled; 123 } 124 } 125 126 function isTurnstileReady() { 127 return ( 128 typeof turnstile !== 'undefined' && 129 typeof turnstile.render === 'function' && 130 typeof turnstile.execute === 'function' 131 ); 132 } 133 134 function setTurnstileToken(form, token) { 135 if (!form) { 136 return; 137 } 138 let field = form.querySelector('[name="cf-turnstile-response"]'); 139 if (!field) { 140 field = document.createElement('input'); 141 field.type = 'hidden'; 142 field.name = 'cf-turnstile-response'; 143 form.appendChild(field); 144 } 145 field.value = token || ''; 146 } 147 148 function clearTurnstileToken(form) { 149 if (!form) { 150 return; 151 } 152 const field = form.querySelector('[name="cf-turnstile-response"]'); 153 if (field) { 154 field.value = ''; 155 } 156 } 157 158 function showTurnstileUnavailableError(form) { 159 if (!form) { 160 return; 161 } 162 if (form.id === 'signin_form') { 163 const errEl = document.getElementById('nk-paywall-signin-error'); 164 if (errEl) { 165 errEl.textContent = TURNSTILE_UNAVAILABLE_MSG; 166 errEl.style.display = ''; 167 } 168 return; 169 } 170 form.dispatchEvent( 171 new CustomEvent('nk-paywall-turnstile-unavailable', { 172 bubbles: true, 173 detail: { message: TURNSTILE_UNAVAILABLE_MSG }, 174 }), 175 ); 176 } 177 178 function renderTurnstileWidget(form, containerId, root) { 179 if (!form || turnstileWidgets.has(form) || !isTurnstileReady()) { 180 return; 181 } 182 183 const container = root.querySelector('#' + containerId); 184 if (!container) { 185 return; 186 } 187 188 const widgetId = turnstile.render(container, { 189 sitekey: nkPaywall.turnstileSiteKey, 190 execution: 'execute', 191 appearance: 'interaction-only', 192 callback(token) { 193 setTurnstileToken(form, token); 194 const pending = turnstilePending.get(form); 195 if (pending) { 196 pending.resolve(token); 197 turnstilePending.delete(form); 198 } 199 }, 200 'error-callback'() { 201 const pending = turnstilePending.get(form); 202 if (pending) { 203 pending.reject(new Error('turnstile-error')); 204 turnstilePending.delete(form); 205 } 206 }, 207 'expired-callback'() { 208 clearTurnstileToken(form); 209 // If a submit is waiting on this token, settle it so the handler's 210 // .catch re-arms the form. Otherwise a native submit would sit with its 211 // button disabled and inflight stuck at '1', unable to retry. 212 const pending = turnstilePending.get(form); 213 if (pending) { 214 pending.reject(new Error('turnstile-expired')); 215 turnstilePending.delete(form); 216 } 217 const widget = turnstileWidgets.get(form); 218 if (widget !== undefined) { 219 turnstile.reset(widget); 220 } 221 }, 222 }); 223 224 turnstileWidgets.set(form, widgetId); 225 } 226
227 // How long a submit waits for Cloudflare's deferred api.js to finish loading and 228 // render the widget before giving up and showing the "still loading" message. 229 const TURNSTILE_READY_TIMEOUT_MS = 5000; 230 const TURNSTILE_READY_POLL_MS = 100; 231 232 // Resolve with the form's widget id once Turnstile is ready and its widget is 233 // rendered, polling until then. A fast submit can land before the deferred api.js 234 // has loaded; rather than failing instantly, wait briefly â turnstileLoaded â 235 // initPopupTurnstile renders the widget as soon as the script arrives, and this 236 // resolves right after. Rejects only if it never becomes ready within the timeout. 237 function waitForTurnstileWidget(form) { 238 const widgetReady = () => 239 isTurnstileReady() && turnstileWidgets.get(form) !== undefined; 240 if (widgetReady()) { 241 return Promise.resolve(turnstileWidgets.get(form)); 242 } 243 return new Promise((resolve, reject) => { 244 const start = Date.now(); 245 const timer = setInterval(() => { 246 if (widgetReady()) { 247 clearInterval(timer); 248 resolve(turnstileWidgets.get(form)); 249 return; 250 } 251 if (Date.now() - start >= TURNSTILE_READY_TIMEOUT_MS) { 252 clearInterval(timer); 253 reject(new Error('turnstile-unavailable')); 254 } 255 }, TURNSTILE_READY_POLL_MS); 256 }); 257 } 258 259 function executeTurnstile(form) { 260 if (!isTurnstileEnabled()) { 261 return Promise.reject(new Error('turnstile-unavailable')); 262 } 263 264 return waitForTurnstileWidget(form).then( 265 (widgetId) => 266 new Promise((resolve, reject) => { 267 turnstilePending.set(form, { resolve, reject }); 268 turnstile.execute(widgetId); 269 }), 270 ); 271 } 272 273 function bindTurnstileForm( 274 { formId, containerId, onVerified }, 275 root = document, 276 ) { 277 const form = root.querySelector('#' + formId); 278 if (!form) { 279 return; 280 } 281 282 // Rendering is idempotent (guarded by the turnstileWidgets WeakMap) and must be 283 // retried whenever Turnstile becomes ready. A form can be bound while 284 // Cloudflare's deferred api.js is still loading: renderTurnstileWidget then 285 // no-ops, and if we early-returned on the bound flag the widget would never get 286 // rendered â every submit would reject as "still loading" forever. So attempt 287 // the render on EVERY call, before the bound early-return, so the later 288 // turnstileLoaded â initPopupTurnstile pass renders it once the script arrives. 289 renderTurnstileWidget(form, containerId, root); 290 291 if (form.dataset.nkPaywallTurnstileBound === '1') { 292 return; 293 } 294 form.dataset.nkPaywallTurnstileBound = '1'; 295 296 // Native-submit forms (sign-in, forgot-password) navigate the page on 297 // submit; the AJAX forms (pro inquiry, fellowship) manage their own 298 // in-flight state and re-enable their buttons downstream. 299 const isNativeSubmit = typeof onVerified !== 'function'; 300 const verified = isNativeSubmit 301 ? () => nativeSubmit(form) 302 : () => onVerified(form); 303 304 form.addEventListener('submit', (event) => { 305 if (form.dataset.nkPaywallTurnstileBypass === '1') { 306 form.dataset.nkPaywallTurnstileBypass = ''; 307 return; 308 } 309 310 if (!isTurnstileEnabled()) { 311 event.preventDefault(); 312 verified(); 313 return; 314 } 315 316 if (!form.checkValidity()) { 317 return; 318 } 319 320 event.preventDefault(); 321 322 // Collapse rapid repeat submits (double-click, Enter held down). 323 // There is an async Turnstile round-trip between this click and the 324 // real nativeSubmit, and each submit reuses the ONE token cached in 325 // the widget. A second POST therefore lands as `timeout-or-duplicate` 326 // server-side and bounces the (already logged-in) user to the 327 // security-check error. Ignore submits while one is already in flight. 328 // Collapse rapid repeat submits for EVERY form: there is now an async
329 // wait+execute round-trip before the real submit, and each submit reuses 330 // the one token cached in the widget, so a second in-flight submit lands 331 // server-side as `timeout-or-duplicate`. Guard all forms (not just native 332 // ones) so the widened wait window can't spawn concurrent executes. 333 if (form.dataset.nkPaywallTurnstileInflight === '1') { 334 return; 335 } 336 form.dataset.nkPaywallTurnstileInflight = '1'; 337 if (isNativeSubmit) { 338 setSubmitButtonsDisabled(form, true); 339 } 340 341 executeTurnstile(form) 342 .then(() => { 343 verified(); 344 // AJAX forms don't navigate; re-arm once their handler has fired so 345 // a later retry can submit. Native forms navigate on success â leave 346 // inflight set so a pre-navigation double-click can't fire a dupe POST. 347 if (!isNativeSubmit) { 348 form.dataset.nkPaywallTurnstileInflight = ''; 349 } 350 }) 351 .catch(() => { 352 form.dataset.nkPaywallTurnstileInflight = ''; 353 if (isNativeSubmit) { 354 setSubmitButtonsDisabled(form, false); 355 } 356 showTurnstileUnavailableError(form); 357 }); 358 }); 359 } 360 361 function initPopupTurnstile(root = document) { 362 turnstileForms.forEach((config) => bindTurnstileForm(config, root)); 363 } 364 365 window.nkPaywallInitPopupTurnstile = initPopupTurnstile; 366 367 window.nkPaywallResetFormTurnstile = function (form) { 368 clearTurnstileToken(form); 369 const widgetId = turnstileWidgets.get(form); 370 if (widgetId !== undefined && isTurnstileReady()) { 371 turnstile.reset(widgetId); 372 } 373 }; 374 375 // `window.turnstileLoaded` is defined at module top level so it exists before 376 // Cloudflare's deferred api.js runs. If Turnstile already loaded before Alpine 377 // initialised this component (the normal case with deferred scripts), bind the 378 // popup forms now â the top-level callback couldn't yet, as this function had 379 // not defined window.nkPaywallInitPopupTurnstile when it fired. 380 if (window.__nkPaywallTurnstileLoaded) { 381 initPopupTurnstile(); 382 } 383 384 const SIGNIN_ERROR_MESSAGES = { 385 failed: 'The email or password you entered is incorrect!', 386 recaptcha: 'Your sign in request was rejected by the security check.', 387 locked: 'Sorry, we have locked your account to prevent malicious access. Please check your email for instructions on how to unlock it!', 388 auth_code: 389 'SECURITY ALERT: To ensure your account remains secure, please enter the token code we just emailed you while logging in.', 390 auth_error: 'Your security token code is wrong.', 391 }; 392 const FORGOT_ERROR_MESSAGES = { 393 empty: 'Enter a valid email address...', 394 recaptcha: 395 'Your password reset request was rejected by the security check.', 396 generic: 'Unable to process your request. Please try again.', 397 }; 398 399 function fixPopupFormUrls(root) { 400 if (!root) return; 401 402 var loc = window.location; 403 var pagePath = loc.pathname; 404 405 var signinForm = root.querySelector('#signin_form'); 406 if (signinForm) { 407 signinForm.action = loc.origin + pagePath + '?popup=signin'; 408 } 409 410 var forgotForm = root.querySelector('#lostpasswordform'); 411 if (forgotForm) { 412 forgotForm.action = 413 loc.origin + pagePath + '?popup=forget-password-form'; 414 } 415 416 var refererInputs = root.querySelectorAll( 417 'input[name="_wp_http_referer"]', 418 ); 419 for (var i = 0; i < refererInputs.length; i++) { 420 refererInputs[i].value = pagePath; 421 } 422 } 423 424 function initResetPasswordForm(root) { 425 if (!root) return; 426 var form = root.querySelector('#resetpw_form'); 427 if (!form) return; 428 429 form.addEventListener('submit', function (e) { 430 e.preventDefault(); 431 432 var errorEl = root.querySelector('#nk-paywall-resetpw-error'); 433 var successEl = root.querySelector('#nk-paywall-resetpw-success'); 434 var submitBtn = form.querySelector('#nk-paywall-resetpw-submit'); 435 436 if (errorEl) { 437 errorEl.style.display = 'none'; 438 errorEl.textContent = ''; 439 } 440 if (successEl) { 441 successEl.style.display = 'none'; 442 successEl.textContent = ''; 443 } 444 445 var password = form.querySelector('input[name="user_password"]'); 446 var confirm = form.querySelector('input[name="confirm_password"]'); 447 if (!password || !password.value || !confirm || !confirm.value) { 448 if (errorEl) { 449 errorEl.textContent = 450 "Sorry, we don't accept empty passwords."; 451 errorEl.style.display = ''; 452 } 453 return; 454 } 455 456 if (submitBtn) { 457 submitBtn.disabled = true; 458 submitBtn.textContent = 'Resetting\u2026'; 459 } 460 461 var body = new FormData(); 462 body.append('action', 'nk_paywall_reset_password'); 463 body.append('nonce', nkPaywall.nonce); 464 body.append( 465 'rp_key', 466 form.querySelector('input[name="rp_key"]').value, 467 ); 468 body.append( 469 'rp_login', 470 form.querySelector('input[name="rp_login"]').value, 471 ); 472 body.append('user_password', password.value); 473 body.append('confirm_password', confirm.value); 474 475 fetch(nkPaywall.ajaxUrl, { method: 'POST', body: body }) 476 .then(function (r) { 477 return r.json(); 478 }) 479 .then(function (res) { 480 if (res.success) { 481 if (successEl) { 482 successEl.textContent = res.data.message; 483 successEl.style.display = ''; 484 } 485 form.style.display = 'none'; 486 var subtitle = root.querySelector( 487 '#nk-paywall-resetpw-subtitle', 488 ); 489 if (subtitle) subtitle.style.display = 'none'; 490 } else { 491 var msg = 492 (res.data && res.data.message) || 493 'An error occurred. Please try again.'; 494 if (errorEl) { 495 errorEl.textContent = msg; 496 errorEl.style.display = ''; 497 } 498 } 499 }) 500 .catch(function () { 501 if (errorEl) { 502 errorEl.textContent =
503 'Network error. Please try again.'; 504 errorEl.style.display = ''; 505 } 506 }) 507 .finally(function () { 508 if (submitBtn) { 509 submitBtn.disabled = false; 510 submitBtn.textContent = 'Reset Password'; 511 } 512 }); 513 }); 514 } 515 516 if (typeof nkPaywall !== 'undefined' && nkPaywall.showDailySubscribe) { 517 Alpine.data('nkPaywallDailySubscribe', () => ({ 518 step: 'email', 519 email: '', 520 firstName: '', 521 loading: false, 522 errorMessage: '', 523 524 init() { 525 // Only the widget on the page should trigger the popup; skip when we're inside the gate modal (injected popup content) 526 if (this.$el && this.$el.closest('#nk-paywall-app')) return; 527 528 if (nkPaywall.loggedIn === '1') return; 529 530 setTimeout(() => { 531 if ( 532 typeof canShowNkPaywallPopup !== 'function' || 533 !canShowNkPaywallPopup() 534 ) 535 return; 536 537 const key = 'subscribe-popup'; 538 const match = document.cookie.match( 539 new RegExp(key + '=([^;]+)'), 540 ); 541 const currentCount = match 542 ? parseInt(match[1], 10) || 0 543 : 0; 544 const newCount = currentCount + 1; 545 document.cookie = 546 key + '=' + newCount + ';expires=0;secure;path=/'; 547 if (currentCount % 5 === 0) { 548 showPopup('daily-subscribe'); 549 } 550 }, 5000); 551 }, 552 553 goToName() { 554 const e = (this.email || '').trim(); 555 if (!e || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)) return; 556 this.step = 'name'; 557 }, 558 559 submit() { 560 if (!this.email.trim() || !this.firstName.trim()) return; 561 this.loading = true; 562 this.errorMessage = ''; 563 const body = new FormData(); 564 body.append('action', 'nk_paywall_daily_subscribe'); 565 body.append( 566 'nonce', 567 typeof nkPaywall !== 'undefined' ? nkPaywall.nonce : '', 568 ); 569 body.append('email', this.email); 570 body.append('fname', this.firstName); 571 fetch( 572 typeof nkPaywall !== 'undefined' ? nkPaywall.ajaxUrl : '', 573 { 574 method: 'POST', 575 body, 576 }, 577 ) 578 .then((r) => r.json()) 579 .then((res) => { 580 this.loading = false; 581 if (res.success) this.step = 'success'; 582 else { 583 this.step = 'error'; 584 this.errorMessage = 585 res.data && (res.data.message || res.data.msg) 586 ? res.data.message || res.data.msg 587 : 'Something went wrong. Please try again.'; 588 } 589 }) 590 .catch(() => { 591 this.loading = false; 592 this.step = 'error'; 593 this.errorMessage = 'Network error. Please try again.'; 594 }); 595 }, 596 })); 597 } 598 599 function isNkproSite() { 600 return typeof nkPaywall !== 'undefined' && nkPaywall.site === 'nkpro'; 601 } 602 603 // The $1 trial is an NK News offer only (see SiteConfig / AccessController). 604 function isNkNewsSite() { 605 return typeof nkPaywall !== 'undefined' && nkPaywall.site === 'nknews'; 606 } 607 608 function resolvePopupSlug(slug) { 609 if (isNkproSite() && (slug === 'signin-form' || slug === 'signup')) { 610 return 'nkpro-members-only'; 611 } 612 return slug; 613 } 614 615 function showPopup(slug, locked, fullscreen, trial) { 616 const resolvedSlug = resolvePopupSlug(slug); 617 if (gateInstance.popup && gateInstance.popup !== resolvedSlug) { 618 gateInstance.closePopup(); 619 } 620 if (!gateInstance.popup) { 621 gateInstance.showPopup( 622 resolvedSlug, 623 locked, 624 fullscreen, 625 undefined, 626 trial, 627 ); 628 } 629 } 630 631 // Only one NK Paywall modal at a time: do not open daily-subscribe if another popup is already open. 632 function canShowNkPaywallPopup() { 633 return gateInstance && !gateInstance.popup; 634 } 635 636 const TRIAL_LANDING_ORIGIN = 'https://signup.nknews.org'; 637 const TRIAL_LANDING_PATH = '/nknews/1-dollar-trial/'; 638 639 // postMessage needs an exact target origin - '*' would broadcast to whoever 640 // happens to be framing us - and the landing page is shared by production and 641 // staging, so it can't hardcode one. Tell it which origin we are; it checks 642 // that against its own allowlist before posting anything back. 643 function trialLandingUrl() { 644 return ( 645 TRIAL_LANDING_ORIGIN + 646 TRIAL_LANDING_PATH + 647 '?parent=' + 648 encodeURIComponent(window.location.origin) 649 ); 650 } 651 652 // Agreed payload for the landing page's in-page CTAs; see the `message` 653 // listener below. 654 const TRIAL_LANDING_CTA_MESSAGE = 'nk-paywall:trial-cta'; 655 656 // Non-null only while our own trial lightbox is open, so a stray message can 657 // never start the trial flow or close somebody else's modal. 658 let trialLightboxSession = null; 659 660 // $1-trial CTA, logged-out visitor: show the marketing landing page 661 // (signup.nknews.org) in a bs5-lightbox first; once the visitor closes it, 662 // `onClose` opens the real signup-form popup. bs5-lightbox reads its 663 // config off data-* attributes on the element passed to its constructor, 664 // so a detached <a> is enough - it never gets inserted into the DOM. 665 // Bootstrap's modal events bubble to `document`, so a one-time listener 666 // there catches the close without needing bs5-lightbox's internal modal 667 // node. 668 function openTrialLandingLightbox(onClose) { 669 if (typeof Lightbox === 'undefined') { 670 // Library failed to load: don't block the trial flow on it. 671 onClose(); 672 return; 673 } 674 675 const trigger = document.createElement('a'); 676 trigger.href = trialLandingUrl(); 677 trigger.setAttribute('data-type', 'url'); 678 679 // Runs exactly once per open, whichever ends it: the visitor dismissing 680 // the lightbox, or a CTA click relayed from inside the landing page. 681 const session = { lightbox: null, done: false }; 682 session.finish = () => { 683 if (session.done) { 684 return; 685 } 686 session.done = true; 687 if (trialLightboxSession === session) { 688 trialLightboxSession = null; 689 } 690 onClose(); 691 }; 692 693 // Only arm the session (and the close handler) once the lightbox itself 694 // exists, so a constructor failure can't leave a stale one behind. 695 session.lightbox = new Lightbox(trigger, { size: 'xl' }); 696 trialLightboxSession = session; 697 698 document.addEventListener('hidden.bs.modal', session.finish, { 699 once: true, 700 }); 701 702 session.lightbox.show(); 703 } 704 705 // The message must come from the iframe we opened, not from some other frame 706 // that happens to share the landing page's origin. 707 function isTrialLightboxFrame(session, source) { 708 const modalEl = session.lightbox && session.lightbox.modalElement; 709 if (!modalEl || !source) { 710 return false;
711 } 712 return Array.prototype.some.call( 713 modalEl.querySelectorAll('iframe'), 714 (iframe) => iframe.contentWindow === source, 715 ); 716 } 717 718 // bs5-lightbox wraps a real bootstrap Modal, so hide() emits 719 // `hidden.bs.modal` and the flow continues through session.finish() exactly 720 // as it does when the visitor closes the lightbox themselves. 721 function closeTrialLandingLightbox(session) { 722 if (session.lightbox && typeof session.lightbox.hide === 'function') { 723 session.lightbox.hide(); 724 return; 725 } 726 session.finish(); 727 } 728 729 // The landing page renders in a cross-origin iframe, so the host page's own 730 // click handlers can't see its in-page "start trial" CTAs. Those CTAs signal 731 // us instead with window.parent.postMessage('nk-paywall:trial-cta', origin). 732 // Any window can post to any other, so the signal is only honoured when all 733 // of these hold: our trial lightbox is currently open, the message came from 734 // the landing page's origin, it came from that lightbox's own frame, and it 735 // carries the exact agreed payload. Even then it only closes the lightbox â 736 // the resulting `hidden.bs.modal` runs the same onClose a manual dismissal 737 // would, so a forged message could do nothing the visitor can't already do. 738 window.addEventListener('message', (event) => { 739 const session = trialLightboxSession; 740 if (!session || session.done) { 741 return; 742 } 743 if (event.origin !== TRIAL_LANDING_ORIGIN) { 744 return; 745 } 746 const data = event.data; 747 const type = typeof data === 'string' ? data : data && data.type; 748 if (TRIAL_LANDING_CTA_MESSAGE !== type) { 749 return; 750 } 751 if (!isTrialLightboxFrame(session, event.source)) { 752 return; 753 } 754 closeTrialLandingLightbox(session); 755 }); 756 757 // Capture: .nk-paywall-modal uses @click.stop so bubble-phase listeners on document never run for in-modal clicks. 758 document.addEventListener( 759 'click', 760 (e) => { 761 const popupAttribute = 'data-nk-paywall-popup'; 762 const trialAttribute = 'data-nk-paywall-trial'; 763 const btn = e.target.closest(`[${popupAttribute}]`); 764 // return first 765 766 if (!btn || !btn.getAttribute(popupAttribute)) { 767 return; 768 } 769 770 e.preventDefault(); 771 const slug = btn.getAttribute(popupAttribute); 772 const trial = 773 'signup-form' === slug && 774 'true' === btn.getAttribute(trialAttribute); 775 776 if (trial && gateInstance && !gateInstance.loggedIn) { 777 openTrialLandingLightbox(() => { 778 showPopup(slug, undefined, undefined, trial); 779 }); 780 return; 781 } 782 783 showPopup(slug, undefined, undefined, trial); 784 }, 785 true, 786 ); 787 788 // Intent-based prefetch: warm the popup cache the moment the user shows intent 789 // (hover or keyboard focus on a trigger), so the eventual click opens instantly. 790 // Deduped per cache key in _fetchPopupHtml, so repeated events cost nothing. 791 const prefetchFromIntent = (e) => { 792 if ( 793 !gateInstance || 794 !e.target || 795 typeof e.target.closest !== 'function' 796 ) { 797 return; 798 } 799 const btn = e.target.closest('[data-nk-paywall-popup]'); 800 const slug = btn && btn.getAttribute('data-nk-paywall-popup'); 801 if (slug) { 802 const trial = 803 'signup-form' === slug && 804 'true' === btn.getAttribute('data-nk-paywall-trial'); 805 gateInstance.prefetchPopup(slug, trial); 806 } 807 }; 808 document.addEventListener('pointerover', prefetchFromIntent, true); 809 document.addEventListener('focusin', prefetchFromIntent, true); 810 811 window.addEventListener('nk-paywall:open-popup', (event) => { 812 const slug = event.detail && event.detail.slug; 813 if (!slug) { 814 return; 815 } 816 const locked = 817 typeof event.detail.locked === 'boolean' 818 ? event.detail.locked 819 : undefined; 820 const fullscreen = 821 typeof event.detail.fullscreen === 'boolean' 822 ? event.detail.fullscreen 823 : undefined; 824 showPopup(slug, locked, fullscreen); 825 }); 826 827 window.addEventListener('nk-paywall-close', () => { 828 if (gateInstance && !gateInstance.popupLocked) { 829 gateInstance.closePopup(); 830 } 831 }); 832 833 /** 834 * Main paywall gate controller. 835 * Fires AJAX access check on init, handles content reveal / popup display. 836 */ 837 Alpine.data('nkPaywallGate', () => ({ 838 granted: false, 839 popup: null, 840 popupHtml: '',
841 popupLocked: false, 842 popupFullscreen: false, 843 ctaHtml: '', 844 remaining: -1, 845 limit: 0, 846 loggedIn: 847 typeof nkPaywall !== 'undefined' && 848 (nkPaywall.loggedIn === true || nkPaywall.loggedIn === '1'), 849 nkproPopupState: '', 850 loading: true, 851 852 init() { 853 const el = this.$el; 854 el.style.display = ''; 855 856 // Alpine can initialise this component more than once per page 857 // (deferred/delayed script execution, DOM re-walks). Everything below â 858 // registering watchers, binding close handlers, and opening popups from 859 // URL params â must happen exactly once on a single canonical instance. 860 // A second init() would, on the signin-error reload path, re-run 861 // this.showPopup('signin-form') and re-wire popupHtml rendering, leaving a 862 // duplicate form and a stuck backdrop blur after close. Bail out for any 863 // subsequent invocation. 864 if (nkPaywallGateInited) { 865 return; 866 } 867 nkPaywallGateInited = true; 868 gateInstance = this; 869 870 this._bindModalCloseHandlers(el); 871 872 this.$watch('popupHtml', (html) => { 873 this.$nextTick(() => this._renderPopup(html)); 874 }); 875 876 // Lock page scroll whenever any paywall popup is open, not just locked ones. 877 this.$watch('popup', (slug) => { 878 document.body.style.overflow = slug ? 'hidden' : ''; 879 }); 880 881 let skipCheckAccess = false; 882 883 var sp = new URLSearchParams(window.location.search); 884 var loginErr = sp.get('login'); 885 if ( 886 sp.get('popup') === 'resetpw' && 887 sp.get('key') && 888 sp.get('login') 889 ) { 890 this._resetPwKey = sp.get('key'); 891 this._resetPwLogin = sp.get('login'); 892 window.history.replaceState(null, '', window.location.pathname); 893 this.showPopup('reset-password-form'); 894 skipCheckAccess = true; 895 } else if ( 896 sp.get('popup') === 'forget-password-form' && 897 (sp.get('forgot') === 'sent' || sp.get('forgot') === 'failed') 898 ) { 899 var forgotStatus = sp.get('forgot'); 900 var forgotCode = sp.get('forgot_code') || 'generic'; 901 window.history.replaceState(null, '', window.location.pathname); 902 this.showPopup('forget-password-form'); 903 skipCheckAccess = true; 904 905 var forgotPollTimer = setInterval(function () { 906 var errorEl = document.getElementById( 907 'nk-paywall-forget-error', 908 ); 909 var successEl = document.getElementById( 910 'nk-paywall-forget-success', 911 ); 912 var titleEl = document.getElementById( 913 'nk-paywall-forget-title', 914 ); 915 var iconEl = document.getElementById( 916 'nk-paywall-forget-mail-icon', 917 ); 918 var subtitleEl = document.getElementById( 919 'nk-paywall-forget-subtitle', 920 ); 921 var formEl = document.getElementById('lostpasswordform'); 922 923 if (!errorEl || !successEl) { 924 return; 925 } 926 927 clearInterval(forgotPollTimer); 928 929 if (forgotStatus === 'sent') { 930 var forgetRoot = 931 document.getElementById('forget-password'); 932 if (forgetRoot) { 933 forgetRoot.classList.add( 934 'nk-paywall-forget--email-sent', 935 ); 936 } 937 successEl.style.display = 'none'; 938 if (iconEl) iconEl.style.display = 'block'; 939 if (titleEl) titleEl.textContent = 'Email Sent'; 940 if (subtitleEl) { 941 subtitleEl.textContent = 942 'If an account is linked to the email address you provided, a password reset link will arrive to that inbox shortly.'; 943 subtitleEl.style.display = ''; 944 } 945 if (formEl) formEl.style.display = 'none'; 946 } else { 947 if (iconEl) iconEl.style.display = 'none'; 948 errorEl.textContent = 949 FORGOT_ERROR_MESSAGES[forgotCode] || 950 FORGOT_ERROR_MESSAGES.generic; 951 errorEl.style.display = ''; 952 } 953 }, 150); 954 955 setTimeout(function () { 956 clearInterval(forgotPollTimer); 957 }, 10000); 958 } else if ( 959 sp.get('popup') === 'signin' && 960 loginErr && 961 SIGNIN_ERROR_MESSAGES[loginErr] 962 ) { 963 this._signinError = loginErr; 964 window.history.replaceState(null, '', window.location.pathname); 965 this.showPopup('signin-form'); 966 skipCheckAccess = true; 967 968 var errorMsg = SIGNIN_ERROR_MESSAGES[loginErr]; 969 var isAuthCode = 970 loginErr === 'auth_code' || loginErr === 'auth_error'; 971 var pollTimer = setInterval(function () { 972 var el = document.getElementById('nk-paywall-signin-error'); 973 if (el) { 974 clearInterval(pollTimer); 975 el.textContent = errorMsg; 976 el.style.display = ''; 977 if (isAuthCode) { 978 window.dispatchEvent( 979 new CustomEvent('set-auth-mode'), 980 ); 981 } 982 } 983 }, 150); 984 setTimeout(function () { 985 clearInterval(pollTimer); 986 }, 10000); 987 } else if (sp.get('popup') === 'signin' && !loginErr) { 988 window.history.replaceState(null, '', window.location.pathname); 989 this.showPopup('signin-form'); 990 skipCheckAccess = true; 991 } else if (sp.get('popup') === 'signup' && isNkproSite()) { 992 this.nkproPopupState = this.loggedIn 993 ? 'logged_in_upgrade' 994 : 'logged_out_signin'; 995 window.history.replaceState(null, '', window.location.pathname); 996 if ( 997 this.loggedIn && 998 typeof nkPaywall !== 'undefined' && 999 nkPaywall.postId > 0 1000 ) { 1001 this._urlPopupPending = 'nkpro-members-only'; 1002 } else { 1003 skipCheckAccess = true; 1004 this.showPopup('nkpro-members-only', false, false, true); 1005 } 1006 } else if ( 1007 sp.get('popup') === 'signup' && 1008 (nkPaywall.site === 'nknews' || nkPaywall.site === 'koreapro') 1009 ) { 1010 window.history.replaceState(null, '', window.location.pathname); 1011 this.showPopup('signup-form'); 1012 skipCheckAccess = true; 1013 } else if (sp.get('popup') === 'trial' && isNkNewsSite()) { 1014 // Trial signup form with $1 pricing, no marketing lightbox: a 1015 // visitor arriving on this URL has already come from the landing 1016 // page (or from a link that stands in for it), so re-showing it 1017 // would just put a step back in front of them. 1018 window.history.replaceState(null, '', window.location.pathname); 1019 showPopup('signup-form', undefined, undefined, true); 1020 skipCheckAccess = true; 1021 } else if (sp.get('popup') === 'free-member-form') { 1022 window.history.replaceState(null, '', window.location.pathname); 1023 this.showPopup('free-member-form'); 1024 skipCheckAccess = true; 1025 } else if ( 1026 window.__nkPaywallTossReturnState && 1027 !window.__nkPaywallTossGateHandled 1028 ) { 1029 window.__nkPaywallTossGateHandled = true; 1030 this.showPopup('signup-form'); 1031 skipCheckAccess = true; 1032 } else if ( 1033 !window.__nkPaywallTossGateHandled &&
1034 this._detectTossFailFromUrl(sp) 1035 ) { 1036 window.__nkPaywallTossGateHandled = true; 1037 this.showPopup('signup-form'); 1038 skipCheckAccess = true; 1039 } 1040 1041 if (nkPaywallCheckDone) { 1042 return; 1043 } 1044 nkPaywallCheckDone = true; 1045 1046 // KCNA homepage: never auto-open a gating popup. Explicit popups from 1047 // query params (e.g. redirect ?popup=, signin errors) are handled above. 1048 if (typeof nkPaywall !== 'undefined' && nkPaywall.isKcnaHome) { 1049 skipCheckAccess = true; 1050 } 1051 1052 if (!skipCheckAccess) { 1053 this.checkAccess(); 1054 } 1055 }, 1056 1057 _detectTossFailFromUrl(sp) { 1058 var tossCode = sp.get('code'); 1059 if (!tossCode) return false; 1060 var tossPending = false; 1061 try { 1062 tossPending = 1063 sessionStorage.getItem('nk_paywall_toss_pending') === '1'; 1064 } catch (_) { 1065 /* ignore */ 1066 } 1067 if (!tossPending) return false; 1068 try { 1069 sessionStorage.removeItem('nk_paywall_toss_pending'); 1070 } catch (_) { 1071 /* ignore */ 1072 } 1073 var msg = 1074 sp.get('message') || 1075 tossCode 1076 .replace(/_/g, ' ') 1077 .toLowerCase() 1078 .replace(/^\w/, function (c) { 1079 return c.toUpperCase(); 1080 }); 1081 window.__nkPaywallTossReturnState = { 1082 type: 'fail', 1083 message: msg, 1084 }; 1085 window.history.replaceState(null, '', window.location.pathname); 1086 return true; 1087 }, 1088 1089 _renderPopup(html) { 1090 const container = this.$refs.popupBody; 1091 if (!container) return; 1092 container.innerHTML = html || ''; 1093 if (typeof Alpine !== 'undefined' && Alpine.initTree) { 1094 Alpine.initTree(container); 1095 } 1096 fixPopupFormUrls(container); 1097 initPopupTurnstile(container); 1098 1099 if (this._resetPwKey && this._resetPwLogin) { 1100 var keyInput = container.querySelector('input[name="rp_key"]'); 1101 var loginInput = container.querySelector( 1102 'input[name="rp_login"]', 1103 ); 1104 if (keyInput) keyInput.value = this._resetPwKey; 1105 if (loginInput) loginInput.value = this._resetPwLogin; 1106 } 1107 1108 initResetPasswordForm(container); 1109 }, 1110 1111 checkAccess() { 1112 const body = new FormData(); 1113 body.append('action', 'nk_paywall_check'); 1114 body.append('post_id', nkPaywall.postId); 1115 body.append('referrer', document.referrer || ''); 1116 1117 const urlParams = new URLSearchParams(window.location.search); 1118 const shareToken = urlParams.get('share'); 1119 if (shareToken) { 1120 body.append('share', shareToken); 1121 } 1122 1123 fetch(nkPaywall.ajaxUrl, { method: 'POST', body }) 1124 .then((r) => r.json()) 1125 .then((res) => { 1126 this.loading = false; 1127 1128 if (!res.success) return; 1129 1130 const d = res.data; 1131 this.granted = d.granted; 1132 1133 // Korea-based visitors: point header KCNA link at the proxy domain. 1134 if (typeof d.is_kor !== 'undefined' && d.is_kor) { 1135 document.querySelectorAll('.kcna_url').forEach((el) => { 1136 el.setAttribute('href', 'https://' + d.is_kor); 1137 }); 1138 } 1139 const popupLocked = !!d.popup_locked; 1140 const popupFullscreen = 1141 typeof d.popup_fullscreen === 'boolean' 1142 ? d.popup_fullscreen 1143 : popupLocked; 1144 1145 if (!this.popupLocked) {
1146 this.popupLocked = !d.granted && popupLocked; 1147 } 1148 if (!this.popupFullscreen) { 1149 this.popupFullscreen = !d.granted && popupFullscreen; 1150 } 1151 this.loggedIn = d.logged_in; 1152 this.nkproPopupState = 1153 typeof d.nkpro_popup_state === 'string' 1154 ? d.nkpro_popup_state 1155 : ''; 1156 this.remaining = d.remaining ?? -1; 1157 this.limit = d.limit ?? 0; 1158 1159 // CTA area: spinner until AJAX returns; then CTA HTML or hide when granted. 1160 const ctaContainer = 1161 document.querySelector('#cta-container'); 1162 if (ctaContainer) { 1163 if (d.granted) { 1164 ctaContainer.style.display = 'none'; 1165 ctaContainer.innerHTML = ''; 1166 ctaContainer.classList.remove( 1167 'nk-paywall-cta-loading', 1168 ); 1169 } else { 1170 ctaContainer.style.display = ''; 1171 if (d.cta_html) { 1172 ctaContainer.innerHTML = d.cta_html; 1173 ctaContainer.classList.remove( 1174 'nk-paywall-cta-loading', 1175 ); 1176 } 1177 } 1178 } 1179 1180 if (d.granted && d.content) { 1181 this.revealContent(d.content); 1182 } 1183 1184 if ( 1185 d.granted && 1186 typeof jQuery !== 'undefined' && 1187 typeof jQuery.fn.bxSlider === 'function' 1188 ) { 1189 const articleEl = 1190 document.querySelector('.editor-content') || 1191 document.querySelector('.news-full-desc'); 1192 if (articleEl) { 1193 const $unslid = jQuery(articleEl) 1194 .find('.gallery') 1195 .filter(function () { 1196 return !jQuery(this).closest('.bx-wrapper') 1197 .length; 1198 }); 1199 if ($unslid.length) { 1200 $unslid.bxSlider({ 1201 slideSelector: '.gallery-item', 1202 mode: 'fade', 1203 speed: 0, 1204 keyboardEnabled: true, 1205 controls: true, 1206 }); 1207 $unslid 1208 .find('.gallery-item a') 1209 .on('click', function (e) { 1210 e.preventDefault(); 1211 }); 1212 } 1213 } 1214 } 1215 1216 if ( 1217 d.granted && 1218 typeof window.trigger_main === 'function' 1219 ) { 1220 try { 1221 window.trigger_main(); 1222 } catch (_) { 1223 /* legacy Vue 2 helper; may fail on Vue 3 tools */ 1224 } 1225 } 1226 1227 var toolsApp = document.querySelector( 1228 'body.research-tools #app', 1229 ); 1230 if (toolsApp) { 1231 toolsApp.style.visibility = d.granted 1232 ? 'visible' 1233 : 'hidden'; 1234 } 1235 1236 if (d.cta_html) { 1237 this.ctaHtml = d.cta_html; 1238 if (d.granted) { 1239 this.injectCta(); 1240 } 1241 nkTrack('Paywall', 'CTA Shown', d.cta); 1242 } 1243 1244 if ( 1245 d.popup_html && 1246 !d.granted && 1247 !this.popup && 1248 !this._urlPopupPending 1249 ) { 1250 this.popupHtml = d.popup_html; 1251 this.popup = d.popup; 1252 nkTrack('Paywall', 'Popup Shown', d.popup); 1253 } 1254 this.updateSidebar(); 1255 this._flushUrlPopupPending(); 1256 1257 window.dispatchEvent( 1258 new CustomEvent('nk-paywall:access-resolved', { 1259 detail: { 1260 granted: d.granted, 1261 loggedIn: d.logged_in, 1262 }, 1263 }), 1264 ); 1265 }) 1266 .catch(() => { 1267 this.loading = false;
1268 this._flushUrlPopupPending(); 1269 }); 1270 }, 1271 1272 _flushUrlPopupPending() { 1273 if (!this._urlPopupPending) { 1274 return; 1275 } 1276 const slug = this._urlPopupPending; 1277 this._urlPopupPending = null; 1278 this.showPopup(slug, false, false, true); 1279 }, 1280 1281 revealContent(html) { 1282 // Prefer plugin-owned structure: full content comes only from AJAX, inject into empty div. 1283 const pluginFull = document.querySelector('.nk-paywall-full'); 1284 const pluginExcerpt = document.querySelector('.nk-paywall-excerpt'); 1285 const pluginWrap = document.querySelector( 1286 '.nk-paywall-content-wrap', 1287 ); 1288 1289 if (pluginFull && pluginExcerpt) { 1290 if (html) { 1291 pluginFull.innerHTML = html; 1292 } 1293 pluginExcerpt.style.display = 'none'; 1294 pluginFull.style.display = ''; 1295 if (pluginWrap) { 1296 pluginWrap.style.display = ''; 1297 } 1298 this.processEmbeds(pluginFull); 1299 return; 1300 } 1301 1302 // Legacy: theme outputs #fullContent / #excerptContent. 1303 const full = document.getElementById('fullContent'); 1304 const excerpt = document.getElementById('excerptContent'); 1305 const wrapper = document.getElementById('content-wrapper'); 1306 1307 if (full) { 1308 full.innerHTML = html; 1309 full.style.display = ''; 1310 full.classList.remove('d-none'); 1311 } 1312 1313 if (excerpt) { 1314 excerpt.style.display = 'none'; 1315 } 1316 1317 if (wrapper) { 1318 wrapper.classList.remove('limited'); 1319 } 1320 1321 this.processEmbeds(full || wrapper); 1322 1323 // NK Pro: theme uses .news-full-desc.limited with custom excerpt. 1324 const nkproDesc = document.querySelector('.news-full-desc.limited'); 1325 if (nkproDesc) { 1326 if (html) { 1327 nkproDesc.innerHTML = html; 1328 } 1329 nkproDesc.classList.remove('limited'); 1330 this.processEmbeds(nkproDesc); 1331 } 1332 }, 1333 1334 processEmbeds(container) { 1335 if (!container) return; 1336 1337 container.querySelectorAll('iframe').forEach((iframe) => { 1338 if (iframe.dataset.src && !iframe.src) { 1339 iframe.src = iframe.dataset.src; 1340 } 1341 }); 1342 1343 container.querySelectorAll('img[data-lazy-src]').forEach((img) => { 1344 img.src = img.dataset.lazySrc; 1345 }); 1346 1347 // --- Article post-processing (parity with leaky-paywall-ajax script_for_article; 1348 // #fullContent / #print_area â scoped to this container, plugin uses #nk-paywall-content > .nk-paywall-full) --- 1349 1350 // Prevent caption wrapper links from navigating (image captions link to attachment page by default). 1351 container.querySelectorAll('.wp-caption > a').forEach((a) => { 1352 a.addEventListener('click', (e) => e.preventDefault()); 1353 }); 1354 1355 // Open caption-text and image-credit links in a new tab. 1356 container 1357 .querySelectorAll('.wp-caption-text > a, .image-credit a') 1358 .forEach((a) => a.setAttribute('target', '_blank')); 1359 1360 // Image lightbox (lity): same targets as legacy â wp-image-* and gallery thumbs only (not every <img>). 1361 // Capture phase so bxSlider viewport/touch handlers do not consume the event on bubble. 1362 const prevLityCap = container._nkPaywallLityCapture; 1363 if (prevLityCap) { 1364 container.removeEventListener('click', prevLityCap, true); 1365 } 1366 container._nkPaywallLityCapture = function (e) { 1367 if (typeof lity !== 'function') { 1368 return; 1369 } 1370 const t = e.target; 1371 if (!t || !t.closest) { 1372 return; 1373 } 1374 let img = t.closest('img:not(.pdf-download)'); 1375 if (!img && t.matches && t.matches('a')) { 1376 img = t.querySelector('img:not(.pdf-download)'); 1377 } 1378 if (!img || !container.contains(img)) { 1379 return; 1380 } 1381 const inGalleryItem = img.closest('.gallery .gallery-item'); 1382 const isWpMedia = 1383 typeof img.className === 'string' && 1384 img.className.indexOf('wp-image-') !== -1; 1385 if (!inGalleryItem && !isWpMedia) { 1386 return; 1387 } 1388 e.preventDefault(); 1389 e.stopPropagation(); 1390 lity(img.src); 1391 }; 1392 container.addEventListener( 1393 'click', 1394 container._nkPaywallLityCapture, 1395 true, 1396 ); 1397 1398 // Prevent drag and right-click on captioned images (skip .gallery: mousedown preventDefault kills click â breaks lity/bxSlider). 1399 container.querySelectorAll('.wp-caption img').forEach((img) => { 1400 if (img.closest('.gallery')) { 1401 return; 1402 } 1403 img.addEventListener('mousedown', (e) => e.preventDefault()); 1404 img.addEventListener('contextmenu', () => false); 1405 }); 1406 1407 // Wrap bare tables in Bootstrap responsive classes. 1408 container 1409 .querySelectorAll('table:not(.table-bordered)') 1410 .forEach((table) => { 1411 table.classList.add('table', 'table-bordered'); 1412 const wrapper = document.createElement('div'); 1413 wrapper.className = 'table-responsive'; 1414 table.parentNode.insertBefore(wrapper, table); 1415 wrapper.appendChild(table); 1416 }); 1417 1418 // Wrap YouTube iframes in a 16:9 ratio container (skip Datawrapper embeds). 1419 container.querySelectorAll('iframe').forEach((iframe) => { 1420 const src = iframe.getAttribute('src') || ''; 1421 if ( 1422 !src.includes('youtu') || 1423 (iframe.id && iframe.id.startsWith('datawrapper')) || 1424 iframe.classList.contains('embed-responsive-item') 1425 ) { 1426 return; 1427 } 1428 iframe.classList.add('embed-responsive-item'); 1429 const wrapper = document.createElement('div'); 1430 wrapper.className = 'ratio ratio-16x9'; 1431 iframe.parentNode.insertBefore(wrapper, iframe); 1432 wrapper.appendChild(iframe); 1433 }); 1434 1435 // Gallery slider via bxSlider (jQuery plugin enqueued by themes on singles). 1436 if ( 1437 typeof jQuery !== 'undefined' && 1438 typeof jQuery.fn.bxSlider === 'function' 1439 ) { 1440 const $galleries = jQuery(container).find('.gallery'); 1441 if ($galleries.length) { 1442 $galleries.bxSlider({ 1443 slideSelector: '.gallery-item', 1444 mode: 'fade', 1445 speed: 0, 1446 keyboardEnabled: true, 1447 controls: true, 1448 }); 1449 } 1450 } 1451 1452 // Prevent gallery item links from navigating (slider handles display). 1453 container 1454 .querySelectorAll('.gallery .gallery-item a') 1455 .forEach((a) => { 1456 a.addEventListener('click', (e) => e.preventDefault()); 1457 }); 1458 1459 // TwentyTwenty image comparison re-init after AJAX content injection. 1460 if ( 1461 typeof jQuery !== 'undefined' && 1462 typeof jQuery.fn.twentytwenty === 'function' 1463 ) { 1464 const $t20 = jQuery(container).find('.twentytwenty-container'); 1465 if ($t20.length) { 1466 setTimeout(() => $t20.twentytwenty(), 1000); 1467 } 1468 } 1469 }, 1470 1471 injectCta() { 1472 if (!this.ctaHtml) return; 1473 1474 const target = 1475 document.querySelector('.nk-paywall-full') || 1476 document.getElementById('fullContent') || 1477 document.getElementById('content-wrapper') || 1478 document.querySelector('.news-full-desc'); 1479 1480 if (target && target.parentNode) { 1481 let wrapper = target.nextElementSibling; 1482 1483 if ( 1484 !wrapper || 1485 !wrapper.classList.contains('nk-paywall-cta-wrapper') 1486 ) { 1487 wrapper = document.createElement('div'); 1488 wrapper.className = 'nk-paywall-cta-wrapper'; 1489 target.parentNode.insertBefore(wrapper, target.nextSibling); 1490 } 1491 1492 wrapper.innerHTML = this.ctaHtml; 1493 } 1494 }, 1495 1496 updateSidebar() { 1497 const widgets = document.querySelectorAll( 1498 '[data-nk-paywall-sidebar]', 1499 ); 1500 if (!widgets.length || this.remaining < 0) return; 1501
1502 widgets.forEach((widget) => { 1503 widget.style.display = ''; 1504 const counter = widget.querySelector('[data-remaining]'); 1505 if (counter) { 1506 counter.textContent = this.remaining + '/' + this.limit; 1507 } 1508 }); 1509 }, 1510 1511 // State that varies the rendered popup HTML for a given slug (only nkpro 1512 // members-only carries an extra state). Used to build the cache key. 1513 _popupState(slug) { 1514 return slug === 'nkpro-members-only' && 1515 typeof this.nkproPopupState === 'string' && 1516 this.nkproPopupState 1517 ? this.nkproPopupState 1518 : ''; 1519 }, 1520 1521 // Cache key for a resolved slug. remaining/limit are included because some 1522 // templates interpolate the article counter, so a different count = different HTML. 1523 _popupCacheKey(slug, state, trial) { 1524 return [ 1525 slug, 1526 state || '', 1527 this.remaining, 1528 this.limit, 1529 trial ? '1' : '0', 1530 ].join('|'); 1531 }, 1532 1533 // Fetch (and cache) the rendered popup HTML for a resolved slug. 1534 // Returns a Promise resolving to the HTML string, or null on failure. 1535 // Concurrent calls for the same key share a single in-flight request, so a 1536 // hover-prefetch followed by a click never fires two server requests. 1537 _fetchPopupHtml(slug, state, trial) { 1538 this._popupCache = this._popupCache || {}; 1539 const key = this._popupCacheKey(slug, state, trial); 1540 if (this._popupCache[key]) { 1541 return this._popupCache[key]; 1542 } 1543 1544 const body = new FormData(); 1545 body.append('action', 'nk_paywall_get_popup'); 1546 body.append('popup', slug); 1547 body.append('remaining', this.remaining); 1548 body.append('limit', this.limit); 1549 if (slug === 'nkpro-members-only' && state) { 1550 body.append('nkpro_popup_state', state); 1551 } 1552 if (slug === 'signup-form' && trial) { 1553 body.append('trial', '1'); 1554 } 1555 1556 const promise = fetch(nkPaywall.ajaxUrl, { method: 'POST', body }) 1557 .then((r) => r.json()) 1558 .then((res) => { 1559 if (res && res.success && res.data && res.data.html) { 1560 return res.data.html; 1561 } 1562 throw new Error('nk-paywall: empty popup response'); 1563 }) 1564 .catch(() => { 1565 // Don't cache failures â allow a retry on the next trigger. 1566 delete this._popupCache[key]; 1567 return null; 1568 }); 1569 1570 this._popupCache[key] = promise; 1571 return promise; 1572 }, 1573 1574 // Warm the cache on user intent (hover / keyboard focus) before the click. 1575 // Harmless if it's never opened; the result is just held for the eventual open. 1576 prefetchPopup(rawSlug, trial) { 1577 if (!rawSlug) { 1578 return; 1579 } 1580 const slug = resolvePopupSlug(rawSlug); 1581 this._fetchPopupHtml(slug, this._popupState(slug), trial); 1582 }, 1583 1584 showPopup(slug, locked, fullscreen, force, trial) { 1585 const forceOpen = force === true; 1586 if (this.popup && !forceOpen) { 1587 return; 1588 } 1589 if (this.popup && forceOpen) { 1590 if (this._popupHtmlClearTimer) { 1591 clearTimeout(this._popupHtmlClearTimer); 1592 this._popupHtmlClearTimer = null; 1593 } 1594 this.popup = null; 1595 this.popupLocked = false; 1596 this.popupFullscreen = false; 1597 this.popupHtml = ''; 1598 } 1599 slug = resolvePopupSlug(slug); 1600 const popupLocked = typeof locked === 'boolean' ? locked : false; 1601 const popupFullscreen = 1602 typeof fullscreen === 'boolean' ? fullscreen : popupLocked; 1603 nkTrack('Paywall', 'Popup Opened', slug); 1604 1605 this._fetchPopupHtml(slug, this._popupState(slug), trial).then( 1606 (html) => { 1607 if (html && (forceOpen || !this.popup)) { 1608 if (this._popupHtmlClearTimer) { 1609 clearTimeout(this._popupHtmlClearTimer); 1610 this._popupHtmlClearTimer = null; 1611 } 1612 this.popupHtml = html; 1613 this.popup = slug;
1614 this.popupLocked = popupLocked; 1615 this.popupFullscreen = popupFullscreen; 1616 } 1617 }, 1618 ); 1619 }, 1620 1621 _bindModalCloseHandlers(root) { 1622 if (!root || root.dataset.nkPaywallCloseBound === '1') { 1623 return; 1624 } 1625 1626 root.dataset.nkPaywallCloseBound = '1'; 1627 1628 const overlay = root.querySelector('.nk-paywall-overlay'); 1629 const modal = root.querySelector('.nk-paywall-modal'); 1630 const closeBtn = root.querySelector('#nk-paywall-modal__close'); 1631 1632 if (modal) { 1633 modal.addEventListener('click', (event) => { 1634 event.stopPropagation(); 1635 }); 1636 } 1637 1638 if (overlay) { 1639 overlay.addEventListener('click', (event) => { 1640 if (event.target === overlay && !this.popupLocked) { 1641 this.closePopup(); 1642 } 1643 }); 1644 } 1645 1646 if (closeBtn) { 1647 closeBtn.addEventListener('click', () => { 1648 if (!this.popupLocked) { 1649 this.closePopup(); 1650 } 1651 }); 1652 } 1653 }, 1654 1655 closePopup() { 1656 if (this.popupLocked) { 1657 return; 1658 } 1659 this.popup = null; 1660 this.popupLocked = false; 1661 this.popupFullscreen = false; 1662 if (this._popupHtmlClearTimer) { 1663 clearTimeout(this._popupHtmlClearTimer); 1664 this._popupHtmlClearTimer = null; 1665 } 1666 // Clear content after leave transition (modal 0.3s + backdrop 0.2s) so slide-out is visible 1667 this._popupHtmlClearTimer = setTimeout(() => { 1668 this._popupHtmlClearTimer = null; 1669 if (!this.popup) { 1670 this.popupHtml = ''; 1671 } 1672 }, 500); 1673 }, 1674 })); 1675 1676 /** 1677 * Multi-step signup form component (account â plan â payment). 1678 */ 1679 Alpine.data('nkPaywallSignup', () => ({ 1680 isLoggedIn: 1681 typeof nkPaywall !== 'undefined' && nkPaywall.loggedIn === '1', 1682 isTrial: false, 1683 isNknews: 1684 typeof nkPaywall !== 'undefined' && nkPaywall.site === 'nknews', 1685 step: 1, 1686 plans: [], 1687 plansLoading: false, 1688 signupGateway: 'stripe', 1689 selectedPlan: 0, 1690 signupNonce: '', 1691 stripe: null, 1692 cardElement: null, 1693 cardMounted: false, 1694 needsProfile: false, 1695 exUser: false, 1696 discount: 0, 1697 sp: false, 1698 couponError: { error: true, msg: '' }, 1699 tossPayment: null, 1700 tossCustomerKey: '', 1701 supportEmail: 1702 typeof nkPaywall !== 'undefined' && nkPaywall.signupSupportEmail 1703 ? nkPaywall.signupSupportEmail 1704 : '[email protected]', 1705 successCheckIcon: (() => { 1706 const color = 1707 (typeof nkPaywall !== 'undefined' && nkPaywall.primaryColor) || 1708 '#ce2e2c'; 1709 const encoded = encodeURIComponent(color); 1710 return `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='53' height='53' viewBox='0 0 53 53'%3E%3Cpath fill='${encoded}' d='M26.5 0C11.865 0 0 11.865 0 26.5S11.865 53 26.5 53 53 41.135 53 26.5 41.136 0 26.5 0zm-4.861 40.113L9.97 28.445l3.89-3.89 7.779 7.78L39.14 14.831l3.89 3.89L21.639 40.113z'/%3E%3C/svg%3E`; 1711 })(), 1712 details: { 1713 preferred_name: '', 1714 full_name: '', 1715 email: '', 1716 password: '', 1717 name_on_card: '', 1718 agreement: false, 1719 privacy: false, 1720 conf: false, 1721 profession: 1722 typeof nkPaywall !== 'undefined' && nkPaywall.profession 1723 ? nkPaywall.profession 1724 : '', 1725 country: 1726 typeof nkPaywall !== 'undefined' && nkPaywall.country 1727 ? nkPaywall.country 1728 : '', 1729 coupon_code: '',
1730 stripeToken: '', 1731 livemode: false, 1732 plan: 0, 1733 }, 1734 confirmPassword: '', 1735 errors: [], 1736 paymentError: '', 1737 submitting: false, 1738 ajaxUrl: '/wp-admin/admin-ajax.php', 1739 ngoDocumentFile: null, 1740 ngoDocumentLabel: '', 1741 ngoDocumentOnFile: 1742 typeof nkPaywall !== 'undefined' && !!nkPaywall.ngoDocumentOnFile, 1743 1744 formatPrice(amount) { 1745 const n = Number(String(amount).replace(/,/g, '')); 1746 if (this.signupGateway === 'toss') { 1747 return Math.round(n).toLocaleString('en-US', { 1748 maximumFractionDigits: 0, 1749 minimumFractionDigits: 0, 1750 }); 1751 } 1752 return (Math.round(n * 100) / 100).toFixed(2); 1753 }, 1754 1755 init() { 1756 if (typeof nkPaywall !== 'undefined' && nkPaywall.ajaxUrl) { 1757 this.ajaxUrl = nkPaywall.ajaxUrl; 1758 } 1759 1760 this.isTrial = this.$el.dataset.trial === '1'; 1761 1762 var tossState = window.__nkPaywallTossReturnState || null; 1763 1764 if (tossState) { 1765 if (tossState.type === 'fail') { 1766 this.paymentError = 1767 tossState.message || 1768 'Payment failed. Please try again.'; 1769 this.$nextTick(() => { 1770 window.__nkPaywallTossReturnState = null; 1771 }); 1772 return; 1773 } 1774 1775 if (tossState.type === 'success') { 1776 this.step = 4; 1777 if (!window.__nkPaywallTossFinalizing) { 1778 window.__nkPaywallTossFinalizing = true; 1779 var tossReturn = 1780 typeof window !== 'undefined' 1781 ? window.nkPaywallTossReturn 1782 : null; 1783 this.$nextTick(() => { 1784 window.__nkPaywallTossReturnState = null; 1785 this.finalizeTossReturn(tossState, tossReturn); 1786 }); 1787 } 1788 return; 1789 } 1790 } 1791 1792 if (this.isLoggedIn) { 1793 const userData = 1794 typeof nkPaywall !== 'undefined' && nkPaywall.userData 1795 ? nkPaywall.userData 1796 : null; 1797 if (userData) { 1798 this.details.email = userData.email || this.details.email; 1799 this.details.preferred_name = 1800 userData.preferred_name || this.details.preferred_name; 1801 this.details.full_name = 1802 userData.full_name || this.details.full_name; 1803 this.details.profession = 1804 userData.profession || this.details.profession; 1805 this.details.country = 1806 userData.country || this.details.country; 1807 } 1808 this.details.agreement = true; 1809 this.details.privacy = true; 1810 1811 if (!this.details.profession || !this.details.country) { 1812 this.needsProfile = true; 1813 } else { 1814 this.errors = false; 1815 this.step = 2; 1816 if (this.isTrial) { 1817 this.loadPlans().then(() => this.autoSelectTrialPlan()); 1818 } else { 1819 this.loadPlans(); 1820 } 1821 } 1822 } 1823 }, 1824 1825 async finalizeTossReturn(tossState, tossReturn) { 1826 try { 1827 const result = await tossReturn.finalizeSignup( 1828 tossState.customerKey, 1829 tossState.authKey, 1830 ); 1831 1832 if (!result.ok) { 1833 this.step = 3; 1834 this.paymentError = 1835 result.message || 'Could not complete subscription.'; 1836 return; 1837 } 1838 1839 this.onPaymentSuccess({ 1840 redirect: 1841 result.redirectUrl || 1842 tossReturn.redirectUrl || 1843 (typeof nkPaywall !== 'undefined' && nkPaywall.homeUrl 1844 ? nkPaywall.homeUrl + 'profile' 1845 : '/profile'), 1846 }); 1847 } catch (_) { 1848 this.step = 3; 1849 this.paymentError = 'Could not complete subscription.'; 1850 } 1851 }, 1852 1853 ngoSignupNeedsDocument() { 1854 if (this.details.profession !== 'ngo') { 1855 return false;
1856 } 1857 // Trial signups check out at $1 today; don't gate them on NGO verification. 1858 if (this.isTrial) { 1859 return false; 1860 } 1861 // NGO notice/upload UI is hidden for nknews; don't require what the user has no way to provide. 1862 if (this.isNknews) { 1863 return false; 1864 } 1865 if (this.isLoggedIn && this.ngoDocumentOnFile) { 1866 return false; 1867 } 1868 return true; 1869 }, 1870 1871 pickNgoDocument() { 1872 this.$refs.ngoDocumentInput?.click(); 1873 }, 1874 1875 onNgoDocumentSelected(event) { 1876 const input = event.target; 1877 const f = (input.files && input.files[0]) || null; 1878 input.value = ''; 1879 this.ngoDocumentFile = f; 1880 this.ngoDocumentLabel = f ? f.name : ''; 1881 }, 1882 1883 completeSignupUsesNgoMultipart() { 1884 return ( 1885 this.details.profession === 'ngo' && 1886 this.ngoSignupNeedsDocument() && 1887 !!this.ngoDocumentFile 1888 ); 1889 }, 1890 1891 postCompleteSignup(success) { 1892 const payload = JSON.parse(JSON.stringify(this.details)); 1893 if (this.completeSignupUsesNgoMultipart()) { 1894 const fd = new FormData(); 1895 fd.append('action', 'complete_signup'); 1896 fd.append('data', JSON.stringify(payload)); 1897 fd.append('ngo_document', this.ngoDocumentFile); 1898 jQuery.ajax({ 1899 url: this.ajaxUrl, 1900 type: 'POST', 1901 data: fd, 1902 processData: false, 1903 contentType: false, 1904 dataType: 'json', 1905 success, 1906 }); 1907 return; 1908 } 1909 jQuery.ajax({ 1910 url: this.ajaxUrl, 1911 type: 'post', 1912 dataType: 'json', 1913 data: { 1914 action: 'complete_signup', 1915 data: payload, 1916 }, 1917 success, 1918 }); 1919 }, 1920 1921 get isToss() { 1922 const key = 1923 typeof nkPaywall !== 'undefined' ? nkPaywall.tossClientKey : ''; 1924 return !!(key && this.details.country === 'South Korea'); 1925 }, 1926 1927 ensureTossCustomerKey() { 1928 if (!this.tossCustomerKey) { 1929 this.tossCustomerKey = 1930 'nk_' + 1931 Math.random().toString(36).slice(2) + 1932 Date.now().toString(36); 1933 } 1934 }, 1935 1936 loadTossSdk() { 1937 if (typeof window.TossPayments !== 'undefined') { 1938 return Promise.resolve(); 1939 } 1940 return new Promise((resolve, reject) => { 1941 const s = document.createElement('script'); 1942 s.src = 'https://js.tosspayments.com/v2/standard'; 1943 s.async = true; 1944 s.onload = () => resolve(); 1945 s.onerror = reject; 1946 document.head.appendChild(s); 1947 }); 1948 }, 1949 1950 ensureTossPayment() { 1951 const clientKey = 1952 typeof nkPaywall !== 'undefined' ? nkPaywall.tossClientKey : ''; 1953 if ( 1954 !clientKey || 1955 !this.tossCustomerKey || 1956 typeof window.TossPayments === 'undefined' 1957 ) { 1958 return; 1959 } 1960 const toss = window.TossPayments(clientKey); 1961 this.tossPayment = toss.payment({ 1962 customerKey: this.tossCustomerKey, 1963 }); 1964 }, 1965 1966 unmountCard() { 1967 if (this.cardElement && this.cardMounted) { 1968 try { 1969 this.cardElement.unmount(); 1970 } catch (e) { 1971 /* ignore */ 1972 } 1973 this.cardMounted = false; 1974 } 1975 }, 1976 1977 async loadPlans() { 1978 this.plansLoading = true; 1979 const formData = new FormData(); 1980 formData.append('action', 'load_signup_plans'); 1981 formData.append('nonce', nkPaywall.nonce); 1982 formData.append('is_trial', this.isTrial); 1983 1984 if (!this.isLoggedIn) { 1985 formData.append('email', this.details.email); 1986 formData.append( 1987 'name', 1988 this.details.preferred_name || this.details.full_name, 1989 ); 1990 } 1991 if (!this.isLoggedIn || this.needsProfile) { 1992 formData.append('profession', this.details.profession); 1993 formData.append('country', this.details.country); 1994 } 1995 1996 return fetch(this.ajaxUrl, { 1997 method: 'POST', 1998 body: formData, 1999 }) 2000 .then((r) => r.json()) 2001 .then((res) => { 2002 if (res.ex_user) { 2003 this.exUser = true; 2004 return; 2005 } 2006 this.exUser = false;
2007 this.plans = res.plans || []; 2008 this.signupGateway = 2009 String(res.type || 'stripe').toLowerCase() === 'toss' 2010 ? 'toss' 2011 : 'stripe'; 2012 this.sp = !!res.sp; 2013 this.signupNonce = res.signup_nonce; 2014 return this.initStripe(res.published_key); 2015 }) 2016 .finally(() => { 2017 this.plansLoading = false; 2018 }); 2019 }, 2020 2021 async initStripe(key) { 2022 if (!key) return; 2023 2024 if (!document.querySelector('script[src*="js.stripe.com"]')) { 2025 await new Promise((resolve, reject) => { 2026 const s = document.createElement('script'); 2027 s.src = 'https://js.stripe.com/v3/'; 2028 s.onload = resolve; 2029 s.onerror = reject; 2030 document.head.appendChild(s); 2031 }); 2032 } else { 2033 await new Promise((resolve) => { 2034 const poll = setInterval(() => { 2035 if (typeof window.Stripe !== 'undefined') { 2036 clearInterval(poll); 2037 resolve(); 2038 } 2039 }, 100); 2040 setTimeout(() => { 2041 clearInterval(poll); 2042 resolve(); 2043 }, 10000); 2044 }); 2045 } 2046 2047 this.stripe = window.Stripe(key); 2048 2049 const fontCss = 2050 typeof nkPaywall !== 'undefined' && 2051 nkPaywall.signupStripeFontCss 2052 ? nkPaywall.signupStripeFontCss 2053 : ''; 2054 const fonts = fontCss ? [{ cssSrc: fontCss }] : []; 2055 const accent = 2056 typeof nkPaywall !== 'undefined' && nkPaywall.primaryColor 2057 ? nkPaywall.primaryColor 2058 : '#ce2e2c'; 2059 const elements = this.stripe.elements({ fonts }); 2060 this.cardElement = elements.create('card', { 2061 style: { 2062 base: { 2063 fontSize: '14px', 2064 fontFamily: 2065 '"CircularStd", "Helvetica Neue", Helvetica, Arial, sans-serif', 2066 }, 2067 invalid: { 2068 color: accent, 2069 iconColor: accent, 2070 ':-webkit-autofill': { color: accent }, 2071 }, 2072 }, 2073 }); 2074 }, 2075 2076 mountCard() { 2077 if (this.isToss || !this.cardElement || this.cardMounted) return; 2078 const el = document.getElementById('nk-paywall-stripe-card'); 2079 if (el) { 2080 this.cardElement.mount(el); 2081 this.cardMounted = true; 2082 } 2083 }, 2084 2085 currentPlan() { 2086 return this.selectedPlan > 0 2087 ? this.plans[this.selectedPlan - 1] 2088 : null; 2089 }, 2090 2091 isStudentStripePlan() { 2092 const p = this.currentPlan(); 2093 return p && p.stripeplan && p.stripeplan.indexOf('student') > -1; 2094 }, 2095 2096 choosePlan(index) { 2097 this.unmountCard(); 2098 this.selectedPlan = index + 1; 2099 this.details.plan = this.currentPlan(); 2100 this.resetCoupon(); 2101 this.trackGoal(10); 2102 this.trackPlanGoal('select'); 2103 this.step = 3; 2104 this.$nextTick(() => this.mountCard()); 2105 }, 2106 2107 // $1-trial CTA: the trial is yearly-only, so skip the "Choose your plan" 2108 // step entirely (it's hidden in the step nav whenever isTrial), lock in 2109 // the yearly plan, and land straight on Payment. Applies whether the 2110 // trial was started logged-in (profile complete) or logged-out (right 2111 // after Account Details) â both paths call this once plans have loaded. 2112 autoSelectTrialPlan() { 2113 if (!this.isTrial || this.exUser) { 2114 return; 2115 } 2116 const yearlyIndex = this.plans.findIndex( 2117 (p) => (p.interval || '').toLowerCase() === 'year', 2118 ); 2119 if (yearlyIndex > -1) { 2120 this.choosePlan(yearlyIndex); 2121 } 2122 }, 2123 2124 canGoTo(target) { 2125 if (target === this.step) return false;
2126 if (target === 1) return !this.isLoggedIn || this.needsProfile; 2127 if (target === 2) return this.errors === false; 2128 if (target === 3) 2129 return ( 2130 this.errors === false && 2131 this.selectedPlan > 0 && 2132 this.selectedPlan <= this.plans.length 2133 ); 2134 return false; 2135 }, 2136 2137 goTo(target) { 2138 if (target === this.step) return; 2139 if (target === 1 && this.isLoggedIn && !this.needsProfile) return; 2140 if (target === 2 && this.step === 1) { 2141 this.validateFields(); 2142 return; 2143 } 2144 if (this.canGoTo(target)) { 2145 if (target !== 3 && this.step === 3) { 2146 this.unmountCard(); 2147 } 2148 this.step = target; 2149 if (target === 3) { 2150 this.$nextTick(() => this.mountCard()); 2151 } 2152 } 2153 }, 2154 2155 resetCoupon() { 2156 this.couponError = { error: true, msg: '' }; 2157 this.discount = 0; 2158 this.details.coupon_code = ''; 2159 }, 2160 2161 handleCoupon() { 2162 if (!this.details.coupon_code.length) { 2163 this.couponError.msg = ''; 2164 this.discount = 0; 2165 return; 2166 } 2167 jQuery.ajax({ 2168 url: this.ajaxUrl, 2169 type: 'post', 2170 dataType: 'json', 2171 data: { 2172 action: 'validate_coupon', 2173 coupon_code: this.details.coupon_code, 2174 plan_id: this.currentPlan().plan_id, 2175 }, 2176 success: (result) => { 2177 this.couponError = result; 2178 if (result.error) { 2179 this.discount = 0; 2180 return; 2181 } 2182 this.discount = 2183 result.amount_type === 'percent' 2184 ? (this.currentPlan().price * result.amount) / 100 2185 : result.amount; 2186 }, 2187 }); 2188 }, 2189 2190 hasError(field) { 2191 return ( 2192 Array.isArray(this.errors) && this.errors.indexOf(field) > -1 2193 ); 2194 }, 2195 2196 pwRulePass(rule) { 2197 return ( 2198 !Array.isArray(this.errors) || this.errors.indexOf(rule) === -1 2199 ); 2200 }, 2201 2202 validatePassword() { 2203 this.errors = []; 2204 const pw = this.details.password; 2205 if (pw.length < 8) this.errors.push('minimum'); 2206 if (!/[A-Z]/.test(pw)) this.errors.push('upper'); 2207 if (!/[a-z]/.test(pw)) this.errors.push('lower'); 2208 if (!/[0-9]/.test(pw)) this.errors.push('number'); 2209 if (!/[^A-Za-z0-9]/.test(pw)) this.errors.push('special'); 2210 }, 2211 2212 validateFields() { 2213 this.errors = []; 2214 2215 if (!this.details.preferred_name.length) 2216 this.errors.push('preferred_name'); 2217 2218 if (!this.needsProfile) { 2219 if ( 2220 !this.details.email.length || 2221 !nkPaywallValidateEmail(this.details.email) 2222 ) 2223 this.errors.push('email'); 2224 2225 const pw = this.details.password; 2226 if (pw.length < 8) this.errors.push('minimum'); 2227 if (!/[A-Z]/.test(pw)) this.errors.push('upper'); 2228 if (!/[a-z]/.test(pw)) this.errors.push('lower'); 2229 if (!/[0-9]/.test(pw)) this.errors.push('number'); 2230 if (!/[^A-Za-z0-9]/.test(pw)) this.errors.push('special'); 2231 2232 if (pw !== this.confirmPassword) this.errors.push('cpassword'); 2233 2234 if (!this.details.agreement) this.errors.push('agreement'); 2235 if (!this.details.privacy) this.errors.push('privacy'); 2236 } 2237 2238 if (!this.details.profession) this.errors.push('profession'); 2239 if (!this.details.country) this.errors.push('country'); 2240 2241 this.$nextTick(() => { 2242 if (this.errors.length) return; 2243 2244 this.loadPlans().then(() => { 2245 if (!this.exUser) { 2246 this.errors = false;
2247 this.step = 2; 2248 this.autoSelectTrialPlan(); 2249 } 2250 }); 2251 }); 2252 }, 2253 2254 onSubmit() { 2255 if (this.step !== 3 || this.submitting) return; 2256 this.errors = []; 2257 if (this.ngoSignupNeedsDocument() && !this.ngoDocumentFile) { 2258 this.errors.push('ngo_document'); 2259 } 2260 if (this.isToss) { 2261 if (this.isStudentStripePlan() && !this.details.conf) { 2262 this.errors.push('conf'); 2263 } 2264 if (this.errors.length) return; 2265 this.errors = false; 2266 this.submitting = true; 2267 this.trackPlanGoal('payment'); 2268 this.submitToss(); 2269 return; 2270 } 2271 if (!this.details.name_on_card.trim().length) { 2272 this.errors.push('name_on_card'); 2273 } 2274 if (this.isStudentStripePlan() && !this.details.conf) { 2275 this.errors.push('conf'); 2276 } 2277 if (this.errors.length) return; 2278 this.errors = false; 2279 this.submitting = true; 2280 2281 this.trackPlanGoal('payment'); 2282 2283 this.stripe 2284 .createPaymentMethod('card', this.cardElement) 2285 .then((result) => { 2286 if (result.error) { 2287 this.submitting = false; 2288 this.step = 3; 2289 this.$nextTick(() => { 2290 const el = this.$el.querySelector( 2291 '#nk-paywall-card-errors', 2292 ); 2293 if (el) 2294 el.innerHTML = 2295 '<div>' + result.error.message + '</div>'; 2296 }); 2297 } else { 2298 this.processPayment(result.paymentMethod); 2299 } 2300 }); 2301 }, 2302 2303 async submitToss() { 2304 const errEl = this.$el.querySelector('#nk-paywall-card-errors'); 2305 if (errEl) errEl.innerHTML = ''; 2306 try { 2307 await this.loadTossSdk(); 2308 this.ensureTossCustomerKey(); 2309 this.ensureTossPayment(); 2310 if (!this.tossPayment) { 2311 throw new Error('Toss Payments could not be initialized.'); 2312 } 2313 const plan = this.currentPlan(); 2314 const plain = JSON.parse(JSON.stringify(this.details)); 2315 plain.plan = plan; 2316 const customerData = { 2317 email: plain.email, 2318 password: plain.password, 2319 preferred_name: plain.preferred_name, 2320 full_name: plain.full_name, 2321 stripeplan: plan.stripeplan, 2322 coupon_code: plain.coupon_code || '', 2323 profession: plain.profession, 2324 country: plain.country, 2325 agreement: plain.agreement, 2326 privacy: plain.privacy, 2327 conf: plain.conf, 2328 signup_nonce: this.signupNonce, 2329 }; 2330 sessionStorage.setItem( 2331 'tossCustomerKey_' + this.tossCustomerKey, 2332 JSON.stringify(customerData), 2333 ); 2334 if (this.details.profession === 'ngo' && this.ngoDocumentFile) { 2335 const prepFd = new FormData(); 2336 prepFd.append('action', 'prepare_toss_signup'); 2337 prepFd.append('customerKey', this.tossCustomerKey); 2338 prepFd.append('customerData', JSON.stringify(customerData)); 2339 prepFd.append('ngo_document', this.ngoDocumentFile); 2340 const prepRes = await fetch(this.ajaxUrl, { 2341 method: 'POST', 2342 body: prepFd, 2343 credentials: 'same-origin', 2344 }); 2345 const prepBody = await prepRes.json().catch(() => ({})); 2346 if (!prepRes.ok || prepBody.success !== true) { 2347 let msg = 'Could not prepare signup.'; 2348 const d = prepBody.data; 2349 if (typeof d === 'string') { 2350 msg = d; 2351 } else if (d && typeof d.message === 'string') { 2352 msg = d.message; 2353 } 2354 throw new Error(msg); 2355 } 2356 } else { 2357 try { 2358 await fetch( 2359 this.ajaxUrl + '?action=prepare_toss_signup', 2360 { 2361 method: 'POST', 2362 headers: { 'Content-Type': 'application/json' }, 2363 credentials: 'same-origin', 2364 body: JSON.stringify({ 2365 customerKey: this.tossCustomerKey, 2366 customerData: customerData, 2367 }), 2368 }, 2369 ); 2370 } catch (_) { 2371 /* Optional server backup; sessionStorage may suffice */ 2372 } 2373 } 2374 let successUrl = 2375 typeof nkPaywall !== 'undefined' && nkPaywall.tossSuccessUrl 2376 ? nkPaywall.tossSuccessUrl 2377 : window.location.href; 2378 const home = 2379 typeof nkPaywall !== 'undefined' && nkPaywall.homeUrl 2380 ? nkPaywall.homeUrl 2381 : window.location.origin + '/'; 2382 const sep = home.indexOf('?') === -1 ? '?' : '&'; 2383 const failUrl = 2384 home + 2385 sep + 2386 'nk_paywall_toss=fail&customerKey=' + 2387 encodeURIComponent(this.tossCustomerKey); 2388 try { 2389 sessionStorage.setItem('nk_paywall_toss_pending', '1'); 2390 } catch (_) { 2391 /* ignore */ 2392 } 2393 await this.tossPayment.requestBillingAuth({ 2394 method: 'CARD', 2395 customerEmail: plain.email, 2396 customerName: plain.full_name || plain.preferred_name, 2397 successUrl: successUrl, 2398 failUrl: failUrl, 2399 }); 2400 } catch (e) { 2401 this.submitting = false;
2402 if (errEl) { 2403 errEl.innerHTML = 2404 '<div>' + 2405 (e && e.message 2406 ? e.message 2407 : 'Payment request failed.') + 2408 '</div>'; 2409 } 2410 } 2411 }, 2412 2413 processPayment(pm) { 2414 this.step = 4; 2415 this.details.stripeToken = pm.id; 2416 this.details.livemode = pm.livemode; 2417 this.details.signup_nonce = this.signupNonce; 2418 this.details.plan = this.currentPlan(); 2419 this.details.is_trial = this.isTrial; 2420 if (this.couponError.error) this.details.coupon_code = ''; 2421 2422 this.postCompleteSignup((result) => { 2423 if (result.error) { 2424 this.submitting = false; 2425 this.step = 3; 2426 const el = this.$el.querySelector( 2427 '#nk-paywall-card-errors', 2428 ); 2429 if (el) el.innerHTML = '<div>' + result.msg + '</div>'; 2430 } else if (result.requires_action) { 2431 this.handle3DS(result); 2432 } else { 2433 this.onPaymentSuccess(result); 2434 } 2435 }); 2436 }, 2437 2438 handle3DS(response) { 2439 this.details.customer_id = response.customer_id; 2440 this.details.payment_intent_id = response.payment_intent_id; 2441 2442 this.stripe 2443 .handleCardPayment(response.payment_intent_client_secret) 2444 .then((result) => { 2445 if (result.error) { 2446 this.submitting = false; 2447 this.step = 3; 2448 const el = this.$el.querySelector( 2449 '#nk-paywall-card-errors', 2450 ); 2451 if (el) 2452 el.innerHTML = 2453 '<div>' + 2454 (result.error.message || '') + 2455 '</div>'; 2456 } else { 2457 this.postCompleteSignup((r) => { 2458 if (r.error) { 2459 this.submitting = false; 2460 this.step = 3; 2461 const el = this.$el.querySelector( 2462 '#nk-paywall-card-errors', 2463 ); 2464 if (el) 2465 el.innerHTML = '<div>' + r.msg + '</div>'; 2466 } else { 2467 this.onPaymentSuccess(r); 2468 } 2469 }); 2470 } 2471 }); 2472 }, 2473 2474 onPaymentSuccess(result) { 2475 if (this.details.livemode) { 2476 this.googleConversion(this.details.plan.plan_id); 2477 this.twitterConversion(this.details.plan.plan_id); 2478 } 2479 this.step = 5; 2480 this.trackPlanGoal('complete'); 2481 if (result.redirect) { 2482 setTimeout(() => { 2483 window.location.href = result.redirect; 2484 }, 3000); 2485 } 2486 }, 2487 2488 trackPlanGoal(stage) { 2489 const p = this.currentPlan(); 2490 if (!p) return; 2491 const map = { 2492 Annual: { select: 11, payment: 14, complete: 17 }, 2493 Monthly: { select: 12, payment: 15, complete: 18 }, 2494 Student: { select: 13, payment: 16, complete: 19 }, 2495 }; 2496 if (map[p.name] && map[p.name][stage]) 2497 this.trackGoal(map[p.name][stage]); 2498 }, 2499 2500 trackGoal(goalId) { 2501 if ( 2502 typeof nkPaywall === 'undefined' || 2503 (nkPaywall.site !== 'nknews' && nkPaywall.site !== 'koreapro') 2504 ) { 2505 return; 2506 } 2507 window._paq = window._paq || []; 2508 window._paq.push(['trackGoal', goalId]); 2509 }, 2510 2511 googleConversion(planId) { 2512 const s = document.createElement('script'); 2513 s.async = true;
2514 s.src = 'https://www.googletagmanager.com/gtag/js?id=AW-994398360'; 2515 document.body.appendChild(s); 2516 2517
vendor: 194 bytes, lines 2517-2522
2517window.dataLayer = window.dataLayer || []; 2518 function gtag() { 2519 dataLayer.push(arguments); 2520 } 2521 gtag('js', new Date()); 2522 gtag('config', '
2522AW-994398360
vendor: 17 bytes, lines 2522-2524
2522'); 2523 2524
2524const conversions = { 2525 3: 'AW-994398360/2891CI_1-aIYEJihldoD', 2526 4: 'AW-994398360/2891CI_1-aIYEJihldoD', 2527 5: 'AW-994398360/zs4aCLm4jKMYEJihldoD', 2528 }; 2529 if (conversions[planId]) { 2530 gtag('event', 'conversion', { 2531 send_to: conversions[planId], 2532 transaction_id: '', 2533 }); 2534 } 2535 }, 2536 2537 twitterConversion(planId) { 2538 !(function (e, t, n, s, u, a) { 2539 e.twq || 2540 ((s = e.twq = 2541 function () { 2542 s.exe 2543 ? s.exe.apply(s, arguments) 2544 : s.queue.push(arguments); 2545 }), 2546 (s.version = '1.1'), 2547 (s.queue = []), 2548 (u = t.createElement(n)), 2549 (u.async = !0), 2550 (u.src = 'https://static.ads-twitter.com/uwt.js'), 2551 (a = t.getElementsByTagName(n)[0]), 2552 a.parentNode.insertBefore(u, a)); 2553 })(window, document, 'script'); 2554 twq('init', 'nyi3w'); 2555 twq('track', 'PageView'); 2556 2557 const events = { 2558 3: 'tw-nyi3w-oecjf', 2559 4: 'tw-nyi3w-oecjg', 2560 5: 'tw-nyi3w-oecjh', 2561 }; 2562 if (events[planId]) twq('event', events[planId], {}); 2563 }, 2564 })); 2565 2566 /** 2567 * Sign-in popup: client-side email/password checks (aligned with signup UX). 2568 */ 2569 Alpine.data('nkPaywallSignin', () => ({ 2570 email: '', 2571 password: '', 2572 authCode: '', 2573 mode: 'credentials', 2574 errors: [], 2575 2576 hasError(field) { 2577 return ( 2578 Array.isArray(this.errors) && this.errors.indexOf(field) > -1 2579 ); 2580 }, 2581 2582 clearFieldError(field) { 2583 if (!Array.isArray(this.errors)) { 2584 return; 2585 } 2586 const i = this.errors.indexOf(field); 2587 if (i > -1) { 2588 this.errors.splice(i, 1); 2589 } 2590 }, 2591 2592 validate() { 2593 this.errors = []; 2594 if (this.mode === 'auth_code') { 2595 if (!(this.authCode || '').trim().length) { 2596 this.errors.push('authCode'); 2597 } 2598 } else { 2599 const e = (this.email || '').trim(); 2600 if (!e.length || !nkPaywallValidateEmail(e)) { 2601 this.errors.push('email'); 2602 } 2603 if (!(this.password || '').length) { 2604 this.errors.push('password'); 2605 } 2606 } 2607 return this.errors.length === 0; 2608 }, 2609 2610 onSubmit(event) { 2611 const errEl = document.getElementById('nk-paywall-signin-error'); 2612 if (errEl) { 2613 errEl.style.display = 'none'; 2614 errEl.textContent = ''; 2615 } 2616 if (!this.validate()) { 2617 event.preventDefault(); 2618 } 2619 }, 2620 })); 2621} 2622 2623document.addEventListener('alpine:init', registerNkPaywallAlpineComponents); 2624registerNkPaywallAlpineComponents(); 2625 2626/** 2627 * WordPress [gallery] shortcode outputs static HTML; bxSlider adds .bx-wrapper. 2628 * processEmbeds() runs after AJAX injects article HTML; this covers 2629 * server-rendered full posts (always-visible, ungated config, etc.) on any theme. 2630 * Skips galleries already inside .bx-viewport. 2631 */ 2632(function nkPaywallInitServerRenderedGalleries() { 2633 const selector = 2634 '.news-full-desc .gallery, #fullContent .gallery, .editor-content .gallery'; 2635 2636 function run() { 2637 if ( 2638 typeof jQuery === 'undefined' || 2639 typeof jQuery.fn.bxSlider !== 'function' || 2640 !document.body || 2641 !document.body.classList.contains('single') 2642 ) { 2643 return false; 2644 } 2645 jQuery(selector).each(function () { 2646 const $g = jQuery(this); 2647 if ($g.closest('.bx-viewport').length) { 2648 return; 2649 } 2650 $g.bxSlider({ 2651 slideSelector: '.gallery-item', 2652 mode: 'fade', 2653 speed: 0, 2654 keyboardEnabled: true, 2655 controls: true, 2656 }); 2657 }); 2658 return true; 2659 } 2660 2661 function schedule() { 2662 if (!document.body || !document.body.classList.contains('single')) { 2663 return; 2664 } 2665 if (run()) { 2666 return; 2667 } 2668 if (typeof jQuery === 'undefined') { 2669 return; 2670 } 2671 let attempts = 0; 2672 const max = 100; 2673 const id = window.setInterval(function () { 2674 attempts += 1; 2675 if (run()) { 2676 window.clearInterval(id); 2677 } else if (attempts >= max) { 2678 window.clearInterval(id); 2679 } 2680 }, 50); 2681 } 2682 2683 if (document.readyState === 'loading') { 2684 document.addEventListener('DOMContentLoaded', schedule); 2685 } else { 2686 schedule(); 2687 } 2688})();
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.