1// Goblin Tools Pro â authentication, entitlement, and the API helper. 2// 3// The Pro auth/upgrade flow lives on real PAGES now, not a modal: 4// /pro â signed-out entry: the pitch beside Clerk's SignIn. 5// /pro/join â the create-account wizard: Clerk's SignUp. 6// /pro/upgrade â signed-in-not-Pro: the inline Polar checkout. 7// /Account â the signed-in settings page (unchanged). 8// Clerk uses PATH routing, so it owns its own /pro/* and /pro/join/* step and 9// OAuth-callback sub-paths (the catch-all page routes serve them and Clerk 10// resumes from the URL). On every OTHER page this script only manages chrome 11// (the header button, the announcement banner, the grace bar) and the Pro 12// fetch helper. 13// 14// ClerkJS is loaded LAZILY: an anonymous visitor on an ordinary page downloads 15// zero third-party bytes and makes zero API calls. It loads only on a Pro page, 16// or once a session hint says the visitor is probably signed in. 17// 18// The script comes from our own clerk.goblin.tools subdomain, so it is 19// first-party and ad blockers can't break sign-in. 20 21(function () { 22 'use strict'; 23 24 var GT = window.GT_PRO; 25 if (!GT || !GT.enabled) return; 26 27 var clerkLoading = null; 28 var clerk = null; 29 var session = null; // last /api/account/session response 30 var proDisabled = false; // set when the API says the feature is unavailable 31 var paymentProcessing = false; // on /pro/upgrade while we poll after payment 32 33 function i18n(key, fallback) { 34 return (window.i18n && window.i18n[key]) || fallback; 35 } 36 37 // ---- Which Pro page (if any) are we on ----------------------------------- 38 39 // 'pro' | 'join' | 'upgrade' | null. Set by data-gt-pro-page on the page's 40 // container; null on every ordinary page. 41 function proPage() { 42 var el = document.querySelector('[data-gt-pro-page]'); 43 return el ? el.getAttribute('data-gt-pro-page') : null; 44 } 45 46 function onProPage() { return proPage() !== null; } 47 48 // ---- Page-level error banner (replaces the modal's error box) ------------ 49 50 function showPageError(message) { 51 var box = document.getElementById('gt-pro-page-error'); 52 if (!box) return; 53 // textContent, never innerHTML â some of these strings are API-derived. 54 box.textContent = message || i18n('pro.errorGeneric', 'Something went wrong.'); 55 box.style.display = 'block'; 56 } 57 58 function clearPageError() { 59 var box = document.getElementById('gt-pro-page-error'); 60 if (box) { box.textContent = ''; box.style.display = 'none'; } 61 } 62 63 // ---- ClerkJS loading ---------------------------------------------------- 64 65 function loadClerk() { 66 if (clerkLoading) return clerkLoading; 67 68 clerkLoading = new Promise(function (resolve, reject) { 69 if (!GT.clerkJsUrl || !GT.publishableKey) { 70 reject(new Error('Clerk is not configured.')); 71 return; 72 } 73 74 var script = document.createElement('script'); 75 script.src = GT.clerkJsUrl; 76 script.async = true; 77 script.crossOrigin = 'anonymous'; 78 script.setAttribute('data-clerk-publishable-key', GT.publishableKey); 79 80 script.onload = function () { 81 window.Clerk.load({ 82 publishableKey: GT.publishableKey, 83 appearance: { 84 // Wrap every Clerk rule in `@layer clerk`. Unlayered CSS 85 // beats layered CSS at ANY specificity, so the .cl-* 86 // rules in site.css win without a single !important â 87 // Clerk styles its controls with selectors like 88 // `.cl-internal-xxxx[data-variant="default"]` (0,2,0), 89 // which a plain `.cl-formFieldInput` could not otherwise 90 // beat however the stylesheets are ordered. 91 cssLayerName: 'clerk' 92 }, 93 94 // Where any Clerk-internal cross-flow navigation should land: 95 // our own pages, never Clerk's hosted portal on another host. 96 // (We also hide Clerk's own footer "Sign up" / "Sign in" links 97 // via CSS in favour of our styled .gt-pro-switch links.) 98 signInUrl: '/pro', 99 signUpUrl: '/pro/join', 100 101 // Where Clerk sends the visitor once auth completes â 102 // including the return leg of a Google/Apple OAuth round 103 // trip, which is a top-level navigation Clerk performs 104 // itself. Always the upgrade page; it bounces an already-Pro 105 // visitor on to /Account. (These targets MUST be allow-listed 106 // in the Clerk dashboard or Clerk silently drops them.) 107 signInForceRedirectUrl: '/pro/upgrade', 108 signUpForceRedirectUrl: '/pro/upgrade', 109 signInFallbackRedirectUrl: '/pro/upgrade', 110 signUpFallbackRedirectUrl: '/pro/upgrade', 111 112 // Defense-in-depth against account enumeration. Clerk's 113 // default identifier-step message for an unknown email is 114 // "Couldn't find your account", which confirms which 115 // addresses are registered. Collapsing the not-found and 116 // wrong-password messages to one identical string removes 117 // that signal from the UI. 118 // 119 // This is only the browser-visible half. The frontend API 120 // still returns distinguishable error CODES over the wire; 121 // only Clerk's server-side "Strict user enumeration 122 // protection" (Dashboard â Attack protection) closes that. 123 localization: { 124 unstable__errors: { 125 form_identifier_not_found: i18n('pro.authInvalid', 'The email, username or password you entered is incorrect.'), 126 form_password_incorrect: i18n('pro.authInvalid', 'The email, username or password you entered is incorrect.') 127 } 128 } 129 }) 130 .then(function () { 131 clerk = window.Clerk; 132 clerk.addListener(onClerkChange); 133 resolve(clerk); 134 }) 135 .catch(reject); 136 }; 137 script.onerror = function () { reject(new Error('Failed to load Clerk.')); }; 138 139 document.head.appendChild(script); 140 }); 141 142 return clerkLoading; 143 } 144 145 // ---- Session ------------------------------------------------------------ 146 147 var lastSignedIn = null; 148 149 // Set for a couple of minutes right after a completed checkout 150 // (sessionStorage, so it never outlives the tab). While set, a freshly 151 // loaded ordinary page will NOT treat a signed-in-but-not-yet-Pro visitor as 152 // lapsed/free â so the brief gap between payment and the entitlement webhook 153 // can never flash the wrong chrome at someone who has just paid. Cleared the 154 // moment Pro is confirmed (applyChrome). 155 var JUST_PAID_KEY = 'gtProJustPaid'; 156 157 function readJustPaid() { 158 try { 159 var t = parseInt(sessionStorage.getItem(JUST_PAID_KEY) || '0', 10); 160 if (t && (Date.now() - t) < 120000) return true; 161 sessionStorage.removeItem(JUST_PAID_KEY); 162 } catch (e) { } 163 return false;
164 } 165 166 /** 167 * Clerk fires this for token refreshes as well as real sign-in / sign-out 168 * transitions, so act only on a genuine change. Navigation AFTER auth is 169 * owned by Clerk's force-redirect on the Pro pages; here we only keep the 170 * chrome and the shared-browser cache honest. There is deliberately no 171 * "you must subscribe" lock any more â a signed-in visitor with no 172 * subscription roams the site freely and upgrades from the banner or the 173 * Pro button. 174 */ 175 function onClerkChange() { 176 var signedIn = !!(clerk && clerk.user); 177 if (lastSignedIn === signedIn) return; 178 lastSignedIn = signedIn; 179 180 purgeHtmlCache(); 181 182 if (!signedIn) { 183 session = null; 184 fetch('/api/account/signout', { method: 'POST' }).catch(function () { }); 185 applyChrome(false, false); 186 return; 187 } 188 189 refreshSession(); 190 } 191 192 function refreshSession() { 193 return authFetch('/api/account/session', { method: 'POST' }) 194 .then(function (response) { 195 if (!response || !response.ok) return null; 196 return response.json(); 197 }) 198 .then(function (data) { 199 if (!data) return null; 200 session = data; 201 applyChrome(data.signedIn, data.pro); 202 renderGraceWarning(data); 203 204 // Entitlement can land while we are polling on /pro/upgrade after 205 // a payment â send them to a clean home page as a Pro user. 206 if (data.pro && paymentProcessing) { 207 finishUpgrade(); 208 return data; 209 } 210 window.dispatchEvent(new CustomEvent('gt-pro-session', { detail: data })); 211 return data; 212 }) 213 .catch(function () { return null; }); 214 } 215 216 /** 217 * The service worker caches HTML in DYNAMIC_CACHE and ignores Cache-Control 218 * entirely, so a signed-in page â Pro chrome and all â would otherwise 219 * survive a sign-out and be served offline to whoever uses the browser 220 * next. The SW already has a handler that drops that cache; reuse it on 221 * every auth transition. 222 */ 223 function purgeHtmlCache() { 224 if (!navigator.serviceWorker || !navigator.serviceWorker.controller) return; 225 try { 226 navigator.serviceWorker.controller.postMessage({ type: 'INVALIDATE_LANGUAGE_CACHE' }); 227 } catch (e) { /* best effort */ } 228 } 229 230 /** 231 * Controls that only make sense once you are signed in â the Templates and 232 * Share buttons on Magic ToDo. They render hidden so a signed-out visitor 233 * sees exactly the old toolbar, and are revealed for any SIGNED-IN visitor, 234 * Pro or not: their own click handlers already do the right thing (act for a 235 * subscriber, send a non-subscriber to /pro/upgrade), so showing them is the 236 * whole of the in-place upsell. 237 */ 238 function setSignedInControlsVisible(signedIn) { 239 document.querySelectorAll('.gt-pro-only').forEach(function (el) { 240 // Clearing the inline style reverts to the element's own display 241 // (inline-block for a .btn); 'none' hides it again. 242 el.style.display = '';// signedIn ? '' : 'none'; 243 }); 244 } 245 246 function applyChrome(signedIn, pro) { 247 GT.signedIn = !!signedIn; 248 GT.pro = !!pro; 249 250 // Pro is confirmed â the post-payment grace flag has done its job. 251 if (pro) { try { sessionStorage.removeItem(JUST_PAID_KEY); } catch (e) { } } 252 253 setSignedInControlsVisible(!!signedIn); 254 refreshProBanner(); 255 256 // Class, not id â _HeaderControls renders twice on the homepage. 257 document.querySelectorAll('.gt-pro-btn').forEach(function (btn) { 258 var icon = btn.querySelector('.gt-pro-icon'); 259 var label = btn.querySelector('.gt-pro-label'); 260 // Signed in (subscribed or not) -> the Account button; signed out -> 261 // the "get Pro" button. Being signed in but unsubscribed
261is a real 262 // account state, so it gets the account entry point too. 263 var text = signedIn ? i18n('pro.buttonAccount', 'Account') : i18n('pro.buttonUpgrade', 'Pro'); 264 var tip = signedIn ? i18n('pro.tooltipAccount', 'Your account') 265 : i18n('pro.tooltipUpgrade', 'Get Goblin Tools Pro'); 266 267 if (label) label.textContent = text; 268 if (icon) { 269 icon.classList.toggle('fa-user', !!signedIn); 270 icon.classList.toggle('fa-wand-magic-sparkles', !signedIn); 271 } 272 // The muted, inverted look is the signed-in state; the loud green is 273 // the signed-out one. Toggle it live so the colour tracks a sign-in 274 // or sign-out without waiting for a reload to reset it from the cookie. 275 btn.classList.toggle('gt-pro-btn-account', !!signedIn); 276 btn.setAttribute('aria-label', tip); 277 btn.setAttribute('data-tooltip', tip); 278 }); 279 } 280 281 /** 282 * The lapsed-subscription warning bar. 283 * 284 * Access is still FULL during the grace window, so this warns rather than 285 * blocks. It counts down in days, because "ends soon" tells nobody anything 286 * useful about whether to act today. 287 */ 288 function renderGraceWarning(s) { 289 var bar = document.getElementById('gt-pro-grace'); 290 if (!bar) return; 291 292 if (!s || !s.inGrace) { 293 bar.style.display = 'none'; 294 return; 295 } 296 297 var text = document.getElementById('gt-pro-grace-text'); 298 if (text) text.textContent = graceMessage(s.graceEndsUtc); 299 300 bar.style.display = 'flex'; 301 } 302 303 function graceMessage(graceEndsUtc) { 304 if (!graceEndsUtc) return i18n('pro.graceWarningToday', 'Your subscription has ended. Renew now to keep your access.'); 305 306 // Whole days remaining, rounded up, so "1 more day" covers any part of 307 // the final day rather than flipping to zero at breakfast. 308 var msLeft = new Date(graceEndsUtc).getTime() - Date.now(); 309 var days = Math.ceil(msLeft / 86400000); 310 311 if (days <= 0) return i18n('pro.graceWarningToday', 'Your subscription has ended. Renew now to keep your access.'); 312 if (days === 1) return i18n('pro.graceWarningOneDay', 'Your subscription has ended. Renew today to keep your access.'); 313 314 return i18n('pro.graceWarningDays', 'Your subscription has ended. Renew within {0} days to keep your access.') 315 .replace('{0}', String(days)); 316 } 317 318 // ---- Token + fetch ------------------------------------------------------ 319 320 /** 321 * A fresh Clerk session token. 322 * 323 * NOT named getToken: site.js already defines a global `getToken(filename)` 324 * for the legacy blob-sync SAS, and shadowing it would silently break that. 325 */ 326 function authToken(options) { 327 if (!clerk || !clerk.session) return Promise.resolve(null); 328 return clerk.session.getToken(options || {}).catch(function () { return null; }); 329 } 330 331 /** Checkout and the billing portal, which have their own availability flag. */ 332 function isBillingPath(url) { 333 return String(url || '').indexOf('/api/account/plan') === 0; 334 } 335 336 /** 337 * The one Pro fetch helper: attaches the bearer token, retries once on 401 338 * with a forced-fresh token, sends a signed-in-non-subscriber to the upgrade 339 * page on 403, and disables Pro for the session on 503 from anything other 340 * than billing. 341 */ 342 function authFetch(url, options, _isRetry) { 343 if (proDisabled) return Promise.resolve(null); 344 345 options = options || {}; 346 347 return loadClerk() 348 .then(function () { return authToken(_isRetry ? { skipCache: true } : undefined); }) 349 .then(function (token) { 350 if (!token) return null; 351 352 var headers = Object.assign({}, options.headers || {}, { 353 'Authorization': 'Bearer ' + token 354 }); 355 356 return fetch(url, Object.assign({}, options, { headers: headers })); 357 }) 358 .then(function (response) { 359 if (!response) return null; 360 361 if (response.status === 401 && !_isRetry) { 362 // Token expired between mint and use â Clerk's live ~60s. 363 return authFetch(url, options, true); 364 } 365 366 if (response.status === 403) { 367 // Signed in, not entitled. (Deliberately not 402 â the whole 368 // client contract is 401 = retry, 403 = upsell.) Send them to 369 // the upgrade page â but not if they are already on a Pro 370 // page (e.g. the checkout's own probe), which would loop. 371 if (!onProPage()) gtProUpsell(); 372 return response; 373 } 374 375 if (response.status === 503 && !isBillingPath(url)) { 376 // Pro is not configured on this server at all, so stop asking 377 // for the rest of the session. Scoped deliberately: the 378 // billing endpoints answer 503 for the much narrower 379 // "subscriptions aren't available", reported through 380 // session.billingAvailable, and letting that latch this 381 // switch would turn off sync, history, templates and shares 382 // because one checkout attempt had failed. 383 proDisabled = true; 384 return response; 385 } 386 387 return response; 388 }); 389 } 390 391 // ---- Checkout (create session) ------------------------------------------ 392 393 /** The signed-in user's primary email, if Clerk has it loaded. */ 394 function currentUserEmail() { 395 try { 396 var pe = clerk && clerk.user && clerk.user.primaryEmailAddress; 397 return pe && pe.emailAddress ? pe.emailAddress : null; 398 } catch (e) { return null; } 399 } 400 401 /** 402 * Create a checkout session. Returns the parsed { url, alreadyPro } body, or
403 * throws so callers land on their own error path. Sends the user's email so 404 * the server can prefill it on the checkout when the session token has no 405 * email claim of its own. 406 */ 407 function createTransaction() { 408 var email = currentUserEmail(); 409 var options = { 410 method: 'POST', 411 headers: { 'Content-Type': 'application/json' }, 412 body: JSON.stringify({ email: email }) 413 }; 414 return authFetch('/api/account/plan', options) 415 .then(function (response) { 416 if (!response || !response.ok) { 417 // Surface the provider's own reason (errorCode / requestId, 418 // added server-side) to the console for diagnosis. The 419 // checkout still falls back to the "can't see it" message. 420 if (response && response.clone) { 421 response.clone().json().then(function (body) { 422 if (body && window.console && console.warn) { 423 console.warn('Goblin Pro checkout could not start â', 424 'code:', body.errorCode || body.error, 425 '| requestId:', body.requestId || 'n/a', 426 '| detail:', body.errorDetail || ''); 427 } 428 }).catch(function () { }); 429 } 430 throw new Error('checkout'); 431 } 432 return response.json(); 433 }); 434 } 435 436 // ---- Embedded (Polar) checkout ------------------------------------------ 437 // 438 // The /pro/upgrade page renders Polar's checkout INLINE, in the right-hand 439 // column beside the pitch. Polar's checkout page is embeddable: loaded in an 440 // iframe with ?embed=true&embed_origin=<our origin>, it renders the form 441 // alone (no page chrome) and talks to us over postMessage. We build that 442 // iframe ourselves rather than pulling in @polar-sh/checkout â that library 443 // only knows how to throw the checkout up as a full-screen overlay, and by 444 // hand there is no third-party script to load or be blocked. The one thing 445 // we take from it is the message contract, below. 446 // 447 // Entitlement is granted server-side by the Polar webhook regardless of what 448 // the browser sees; the 'success' message here only lets us start polling 449 // without waiting for the next session refresh. 450 // 451 // Email is prefilled via customer_email on the checkout session (set in 452 // PolarClient from the address createTransaction sends up), so the checkout 453 // opens with the user's email already filled in. 454 455 var POLAR_ORIGINS = ['https://polar.sh', 'https://sandbox.polar.sh']; 456 var POLAR_MSG_TYPE = 'POLAR_CHECKOUT'; 457 458 var checkoutOpen = false; // a checkout is preparing or shown 459 var checkoutLoadTimer = null; 460 var polarFrame = null; // the live checkout <iframe> 461 var polarMsgHandler = null; // its window 'message' listener 462 var polarSucceeded = false; 463 var lastCheckoutId = null; // Polar checkout id, carried to /?checkout_id= for attribution 464 465 function currentTheme() { 466 return document.documentElement.getAttribute('data-bs-theme') === 'dark' 467 ? 'dark' : 'light'; 468 } 469 470 // The checkout url from the API, dressed for embedding: ?embed=true tells 471 // polar.sh to render the bare form, embed_origin is who it may postMessage 472 // back to (and must match the session's embed_origin, set server-side). 473 function embedCheckoutUrl(checkoutUrl) { 474 var u = new URL(checkoutUrl); 475 u.searchParams.set('embed', 'true'); 476 u.searchParams.set('embed_origin', window.location.origin); 477 u.searchParams.set('theme', currentTheme()); 478 return u.toString(); 479 } 480 481 // Remove the live iframe and stop listening. The spinner overlay is left in 482 // place for the next attempt. 483 function teardownCheckoutFrame() { 484 if (polarMsgHandler) { 485 window.removeEventListener('message', polarMsgHandler); 486 polarMsgHandler = null; 487 } 488 if (polarFrame && polarFrame.parentNode) { 489 polarFrame.parentNode.removeChild(polarFrame); 490 } 491 polarFrame = null; 492 } 493 494 // Build the checkout iframe and mount it in the checkout host. The host is 495 // shown immediately with the spinner over it; the spinner clears when the 496 // iframe posts 'loaded'.
497 function mountCheckoutFrame(checkoutUrl) { 498 var host = document.getElementById('gt-pro-checkout-frame'); 499 if (!host) throw new Error('no-frame'); 500 501 teardownCheckoutFrame(); 502 503 var iframe = document.createElement('iframe'); 504 iframe.src = embedCheckoutUrl(checkoutUrl); 505 iframe.title = 'Checkout'; 506 // Apple Pay / Payment Request live behind the Payment Request API, which 507 // an iframe can only use when the parent delegates 'payment' to it. 508 var hosts = POLAR_ORIGINS.join(' '); 509 iframe.setAttribute('allow', 510 "payment 'self' " + hosts + "; publickey-credentials-get 'self' " + hosts + ";"); 511 host.insertBefore(iframe, host.firstChild); 512 polarFrame = iframe; 513 514 polarMsgHandler = function (ev) { 515 if (POLAR_ORIGINS.indexOf(ev.origin) < 0) return; 516 var d = ev.data; 517 if (!d || d.type !== POLAR_MSG_TYPE) return; 518 switch (d.event) { 519 case 'loaded': onPolarLoaded(); break; 520 case 'success': onPolarSuccess(); break; 521 case 'close': onPolarClose(); break; 522 // 'confirmed' â payment submitted, processing. Nothing to do. 523 } 524 }; 525 window.addEventListener('message', polarMsgHandler); 526 } 527 528 function onPolarLoaded() { 529 clearCheckoutLoadTimer(); 530 showCheckoutLoading(false); 531 } 532 533 function onPolarSuccess() { 534 // Paid. The subscription webhook can lag the completion, so tear the 535 // frame down, show the processing state and poll until entitlement lands. 536 polarSucceeded = true; 537 checkoutOpen = false; 538 clearCheckoutLoadTimer(); 539 teardownCheckoutFrame(); 540 paymentProcessing = true; 541 setProcessingMessage(i18n('pro.activating', 'Payment received, activating your subscriptionâ¦')); 542 showProcessing(true); 543 pollForEntitlement(0); 544 } 545 546 // The processing pane's line is shared with the ?upgraded=1 return, so set 547 // it to match what is actually happening. 548 function setProcessingMessage(text) { 549 var el = document.getElementById('gt-pro-processing-msg'); 550 if (el) el.textContent = text; 551 } 552 553 function onPolarClose() { 554 // The checkout asked to close (its own back/cancel). If they completed, 555 // onPolarSuccess already ran; if they backed out, drop the frame and let 556 // them start again from the button. 557 if (polarSucceeded) return; 558 checkoutOpen = false; 559 clearCheckoutLoadTimer(); 560 teardownCheckoutFrame(); 561 showCheckoutFrame(false); 562 showCheckoutContinue(true); 563 } 564 565 /** 566 * Create a Polar checkout session and mount it inline on /pro/upgrade. A 567 * failed session or a frame that never loads both land on the "can't see the 568 * checkout?" message with a retry. 569 */ 570 function startInlineCheckout() { 571 if (checkoutOpen) return; 572 checkoutOpen = true; 573 polarSucceeded = false; 574 575 showCheckoutBlocked(false); 576 showCheckoutContinue(false); 577 showCheckoutFrame(true); 578 showCheckoutLoading(true); 579 580 // If the frame never signals ready, it is almost always a content 581 // blocker or a network problem reaching polar.sh. 'loaded' cancels this. 582 startCheckoutLoadTimer(); 583 584 createTransaction() 585 .then(function (data) { 586 if (data && data.alreadyPro) { 587 // Already a subscriber â nothing to buy. Off to the account. 588 clearCheckoutLoadTimer(); 589 checkoutOpen = false; 590 window.location.replace('/Account'); 591 return; 592 } 593 if (!data || !data.url) throw new Error('no-url'); 594 595 // Keep the checkout id so finishUpgrade can carry it onto the home 596 // landing for revenue attribution. 597 lastCheckoutId = data.checkoutId || null; 598 599 // Mounts polar.sh's checkout inline. Email is already prefilled 600 // on the session (server-side). The spinner clears on 'loaded'.
601 mountCheckoutFrame(data.url); 602 }) 603 .catch(function () { 604 markCheckoutFailed(); 605 }); 606 } 607 608 // Give up on the current attempt and show the can't-load message. 609 function markCheckoutFailed() { 610 clearCheckoutLoadTimer(); 611 checkoutOpen = false; 612 teardownCheckoutFrame(); 613 showCheckoutFrame(false); 614 showCheckoutBlocked(true); 615 } 616 617 function showCheckoutFrame(show) { 618 var el = document.getElementById('gt-pro-checkout-frame'); 619 if (el) el.style.display = show ? '' : 'none'; 620 } 621 622 function showCheckoutLoading(show) { 623 var el = document.getElementById('gt-pro-checkout-loading'); 624 if (el) el.style.display = show ? '' : 'none'; 625 } 626 627 function showCheckoutBlocked(show) { 628 var el = document.getElementById('gt-pro-checkout-blocked'); 629 if (el) el.style.display = show ? '' : 'none'; 630 } 631 632 function showCheckoutContinue(show) { 633 var el = document.getElementById('gt-pro-checkout-continue'); 634 if (el) el.style.display = show ? '' : 'none'; 635 } 636 637 /** 638 * Swap the /pro/upgrade page between its checkout state and the post-payment 639 * "activatingâ¦" state. While processing, every checkout affordance and the 640 * sign-out escape are hidden so nothing can be clicked mid-activation. 641 */ 642 function showProcessing(on) { 643 var p = document.getElementById('gt-pro-processing'); 644 if (p) p.style.display = on ? '' : 'none'; 645 // Swap the whole checkout content (header, value column, checkout, exit) 646 // for the processing spinner in one move. 647 var content = document.getElementById('gt-pro-checkout-content'); 648 if (content) content.style.display = on ? 'none' : ''; 649 } 650 651 function startCheckoutLoadTimer() { 652 clearCheckoutLoadTimer(); 653 checkoutLoadTimer = setTimeout(markCheckoutFailed, 15000); 654 } 655 656 function clearCheckoutLoadTimer() { 657 if (checkoutLoadTimer) { clearTimeout(checkoutLoadTimer); checkoutLoadTimer = null; } 658 } 659 660 /** The "try again" / "go to checkout" button â (re)mounts the checkout. */ 661 window.gtProRetryCheckout = function () { 662 checkoutOpen = false; 663 polarSucceeded = false; 664 showCheckoutBlocked(false); 665 showCheckoutContinue(false); 666 startInlineCheckout(); 667 }; 668 669 // ---- Post-payment ------------------------------------------------------- 670 671 // After a completed checkout the visitor goes straight back to a clean home 672 // page as a Pro user â no welcome or account screen, "nothing more". A full 673 // navigation (rather than an in-page swap) guarantees the server re-renders 674 // with the Pro chrome. 675 // 676 // The Polar checkout id rides along as ?checkout_id so revenue attribution 677 // works: the analytics reads checkout_id off the return URL and looks the 678 // payment up (https://datafa.st/docs/polar-checkout-links). The redirect 679 // fallback instead gets the id from Polar on the success URL, so 680 // lastCheckoutId is null there and this just goes home. 681 function finishUpgrade() { 682 var target = lastCheckoutId 683 ? '/?checkout_id=' + encodeURIComponent(lastCheckoutId) 684 : '/'; 685 window.location.assign(target); 686 } 687 688 function pollForEntitlement(attempt) { 689 if (attempt > 12) { 690 // Payment went through but entitlement has not landed yet (a slow or 691 // still-failing webhook). Go home anyway rather than stranding them 692 // on the page; the "just paid" flag keeps that page from treating 693 // them as free while the webhook catches up, and Pro appears on the 694 // next session check. 695 try { sessionStorage.setItem(JUST_PAID_KEY, String(Date.now())); } catch (e) { } 696 finishUpgrade(); 697 return; 698 } 699 700 loadClerk() 701 .then(refreshSession) 702 .then(function (s) { 703 if (s && s.pro) { finishUpgrade(); return; } 704 setTimeout(function () { pollForEntitlement(attempt + 1); }, 1500); 705 }) 706 .catch(function () { 707 setTimeout(function () { pollForEntitlement(attempt + 1); }, 1500); 708 }); 709 } 710 711 // ---- Mounting Clerk on the /pro and /pro/join pages ---------------------- 712 713 /** Toggle the loading spinner sitting over a Clerk mount target. */ 714 function setClerkLoading(el, loading) { 715 if (!el || !el.closest) return; 716 var wrap = el.closest('.gt-pro-clerk-wrap'); 717 var spinner = wrap && wrap.querySelector('.gt-pro-clerk-spinner'); 718 if (spinner) spinner.style.display = loading ? 'flex' : 'none'; 719 } 720 721 /** 722 * Clear the spinner once Clerk has actually rendered into the target.
723 * mountSignIn/Up return before the form is in the DOM, so watch for the 724 * first child rather than guessing a delay. A backstop timer makes sure a 725 * silently-failed mount never leaves it spinning forever. 726 */ 727 function hideClerkLoadingWhenMounted(el) { 728 if (!el) return; 729 if (el.childElementCount > 0) { setClerkLoading(el, false); return; } 730 731 var observer = new MutationObserver(function () { 732 if (el.childElementCount > 0) { 733 setClerkLoading(el, false); 734 observer.disconnect(); 735 } 736 }); 737 observer.observe(el, { childList: true }); 738 setTimeout(function () { observer.disconnect(); setClerkLoading(el, false); }, 15000); 739 } 740 741 // The appearance passed to the mounted component. Colours come from the 742 // .cl-* rules in site.css (which follow the light/dark toggle); only 743 // non-colour tokens go here, so nothing is frozen at mount time. 744 function clerkComponentAppearance() { 745 return { 746 layout: { 747 // A white sweep across the button that reads as a rendering 748 // glitch on a light background. 749 shimmer: false 750 }, 751 variables: { 752 // Clerk's own default is 0.8125rem (13px), a large part of why 753 // it reads as somebody else's component dropped into the page. 754 fontSize: '0.9375rem', 755 borderRadius: '0.375rem', 756 spacing: '1rem' 757 } 758 }; 759 } 760 761 /** 762 * Mount SignIn (/pro) or SignUp (/pro/join). Routing differs by flow â see the 763 * per-branch comments below: 764 * sign-in â HASH routing, so multi-step factor navigation stays on the page 765 * (a hash change can't reload it) â no re-mount, no code resend. 766 * sign-up â VIRTUAL routing, to dodge a Clerk double-email bug. 767 */ 768 function mountAuthForm(kind) { 769 var isSignUp = kind === 'signup'; 770 var el = document.getElementById(isSignUp ? 'gt-pro-clerk-signup' : 'gt-pro-clerk-signin'); 771 if (!el || el.childElementCount > 0) { setClerkLoading(el, false); return; } 772 773 var options = { 774 // Belt-and-suspenders with the load-time force-redirect: carried on 775 // the sign-in/up resource so it survives the OAuth round trip. 776 forceRedirectUrl: '/pro/upgrade', 777 fallbackRedirectUrl: '/pro/upgrade', 778 appearance: clerkComponentAppearance() 779 }; 780 781 if (isSignUp) { 782 // SIGN-UP â VIRTUAL routing, deliberately NOT path routing. Under 783 // routing:'path' the prebuilt widget sends the email verification code 784 // TWICE: signUp.create() already triggers the email, then the widget 785 // redundantly calls prepareEmailAddressVerification (Clerk bug 786 // clerk/javascript#8684, still open). The user gets two codes and only 787 // the first (from create) is valid. Virtual routing keeps the steps in 788 // memory and sends exactly one. OAuth still returns via the 789 // force-redirect URL above (Clerk processes the callback on the base 790 // page and then honours it). 791 options.routing = 'virtual'; 792 clerk.mountSignUp(el, options); 793 } else { 794 // SIGN-IN â PATH routing. On a plain (non-SPA) page clerk-js drives its 795 // own step navigation with a full page load, so each factor step is a 796 // real navigation under the /pro/{**clerkPath} catch-all. That is fine 797 // for the linear flow (enter email â "check your email"). The one place 798 // it breaks is Clerk's "use another method" link: it navigates to the 799 // SAME /pro/factor-one it is already on, so the browser reloads and the 800 // fresh mount re-sends the code/link â an endless loop. That navigation 801 // is programmatic (window.location), so neither a routing mode nor a 802 // click-guard can stop it; the link is removed in CSS instead 803 // (#gt-pro-clerk-signin .cl-footerAction). Users switch method via the 804 // Google/Apple buttons on the start screen or the edit-email control. 805 // The #8684 double-send doesn't apply to sign-in (signIn.create() 806 // doesn't auto-send a code the way signUp.create() does). 807 options.routing = 'path';
808 options.path = '/pro'; 809 clerk.mountSignIn(el, options); 810 } 811 812 hideClerkLoadingWhenMounted(el); 813 } 814 815 // A Clerk step / OAuth-callback sub-path under the page's base (e.g. 816 // /pro/sso-callback, /pro/join/verify-email-address) rather than the base 817 // page itself. On these, Clerk MUST be mounted so its component can finish 818 // the handshake â never short-circuited by the already-signed-in redirect, 819 // because clerk.user gets set part-way through processing the callback. 820 function onClerkSubPath(kind) { 821 var base = kind === 'signup' ? '/pro/join' : '/pro'; 822 var path = window.location.pathname.replace(/\/+$/, '').toLowerCase(); 823 return path !== base; 824 } 825 826 /** 827 * Cloudflare Turnstile â which powers Clerk's sign-up CAPTCHA â loads from 828 * the THIRD-PARTY host challenges.cloudflare.com, which (unlike ClerkJS) is 829 * not proxied through our first-party clerk.goblin.tools. Ad blockers, 830 * privacy browsers and some corporate networks block that host, and Clerk 831 * then shows a generic "The CAPTCHA failed to load" with no way forward. 832 * 833 * Probe the host up front; if it can't be reached, reveal the hint that 834 * points at the Google/Apple buttons in Clerk's own form (OAuth sign-up 835 * needs no CAPTCHA). A no-cors fetch resolves opaque on success and rejects 836 * only when the request is blocked at the network layer â exactly the signal 837 * we want, and language- and DOM-agnostic (no scraping Clerk's markup). It 838 * never executes the fetched script. No-op unless the hint element is on the 839 * page (i.e. the /pro/join sign-up page). 840 */ 841 function probeCaptchaReachability() { 842 var help = document.getElementById('gt-pro-captcha-help'); 843 if (!help) return; 844 845 fetch('https://challenges.cloudflare.com/turnstile/v0/api.js', { 846 mode: 'no-cors', 847 cache: 'no-store' 848 }).catch(function () { 849 help.style.display = 'block'; 850 }); 851 } 852 853 /** 854 * Init for /pro (signin) and /pro/join (signup). Loads Clerk, then mounts 855 * the form. On the BASE page an already-signed-in visitor is sent on instead 856 * of being shown a form; on a Clerk sub-path the form is always mounted so 857 * the step/OAuth flow can complete (and Clerk itself force-redirects on). 858 */ 859 function initAuthPage(kind) { 860 var isSignUp = kind === 'signup'; 861 var el = document.getElementById(isSignUp ? 'gt-pro-clerk-signup' : 'gt-pro-clerk-signin'); 862 setClerkLoading(el, !!(el && el.childElementCount === 0)); 863 864 // Surface the CAPTCHA-blocked hint early, independent of how fast (or 865 // whether) ClerkJS loads. No-op off the sign-up page. 866 if (isSignUp) probeCaptchaReachability(); 867 868 loadClerk().then(function () { 869 if (!onClerkSubPath(kind) && clerk.user) { 870 // Base page, already signed in. A subscriber (active or in grace) 871 // manages their plan on /Account; anyone else needs to check out. 872 return refreshSession().then(function (s) { 873 if (s && s.pro) window.location.replace('/Account'); 874 else window.location.replace('/pro/upgrade'); 875 }); 876 } 877 mountAuthForm(kind); 878 }).catch(function () { 879 setClerkLoading(el, false); 880 showPageError(i18n('pro.errorGeneric', 'Something went wrong.')); 881 }); 882 } 883 884 /** 885 * Init for /pro/upgrade. Signed-out visitors go to /pro; anyone who already 886 * has a subscription â active OR in the lapse grace â goes to /Account, 887 * because there is nothing to buy: an active subscriber manages their plan 888 * there, and a lapsed one renews by fixing their card in the billing portal 889 * (a new checkout can't create a second subscription, so the server would 890 * only answer "alreadyPro"). Only a genuinely-unsubscribed visitor gets the 891 * inline checkout. Returning from a redirect checkout (?upgraded=1) shows the 892 * processing state and polls instead. 893 */ 894 function initUpgradePage() { 895 var params = new URLSearchParams(window.location.search); 896 if (params.get('upgraded') === '1') { 897 history.replaceState(null, '', window.location.pathname); 898 paymentProcessing = true; 899 setProcessingMessage(i18n('pro.activating', 'Payment received, activating your subscriptionâ¦')); 900 showProcessing(true); 901 pollForEntitlement(0); 902 return; 903 } 904 905 // Spinner up immediately, before ClerkJS even initialises, so the column 906 // reads as "loading" rather than blank during the wait.
907 showCheckoutFrame(true); 908 showCheckoutLoading(true); 909 910 loadClerk().then(function () { 911 if (!clerk.user) { window.location.replace('/pro'); return; } 912 // Start creating the checkout IMMEDIATELY â don't wait on a session 913 // check first. createTransaction returns { alreadyPro } when the 914 // server sees an existing subscription, and startInlineCheckout sends 915 // those visitors to /Account, so that round trip is off the critical 916 // path. Refresh the chrome (header button, banner) in the background. 917 startInlineCheckout(); 918 refreshSession(); 919 }).catch(function () { 920 showPageError(i18n('pro.errorGeneric', 'Something went wrong.')); 921 }); 922 } 923 924 /** 925 * Signing out lands on the home page rather than reloading where you were. 926 * The pages worth being on after signing out are the free ones, and 927 * reloading the settings or upgrade page just shows a signed-out shell. 928 */ 929 window.gtProSignOut = function () { 930 loadClerk() 931 .then(function () { return clerk.signOut(); }) 932 .then(function () { 933 // Clear the server-side hint cookie and WAIT for it before 934 // navigating. onClerkChange fires this same request on sign-out, 935 // but nothing awaits it, so the redirect to '/' can beat it and 936 // the reloaded page still carries a "signed in" gt_pro cookie. 937 return fetch('/api/account/signout', { method: 'POST' }).catch(function () { }); 938 }) 939 .then(function () { window.location.href = '/'; }) 940 .catch(function () { showPageError(i18n('pro.errorGeneric', 'Something went wrong.')); }); 941 }; 942 943 // ---- Upsell entry points ------------------------------------------------ 944 945 /** 946 * Where "get Pro" leads: a signed-out visitor creates an account first 947 * (/pro/join), a signed-in one goes to checkout (/pro/upgrade), which itself 948 * bounces an already-Pro visitor on to /Account. Every Pro CTA uses this. 949 * The one exception is the header Pro button, which deliberately sends a 950 * signed-out visitor to the sign-in page (/pro) â see onProButtonClick. 951 */ 952 function upsellUrl() { 953 return GT.signedIn ? '/pro/upgrade' : '/pro/join'; 954 } 955 956 /** 957 * The single "take me to Pro" navigation, used by the grace-bar renew button 958 * and as the fallback for the feature gates when there is no control to 959 * anchor a popover to. 960 */ 961 window.gtProUpsell = function () { 962 window.location.href = upsellUrl(); 963 }; 964 965 // ---- The in-place feature gate (a small anchored popover) ---------------- 966 // 967 // When a signed-in-but-not-subscribed visitor clicks a Pro-only control 968 // (Share a list, Save as template) we don't yank them off to checkout â we 969 // float a small, friendly bubble next to the control they clicked, and let 970 // the "Get Pro" button be their own deliberate next step. 971 972 var gateEl = null; // the live popover, if any 973 var gateAnchor = null; // the control it belongs to (for focus return) 974 var gateOutsideHandler = null; 975 var gateSeq = 0; // unique id source for aria-labelledby wiring 976 977 var GATE_MESSAGES = { 978 share: ['pro.gateShare', 'Sharing lists is part of Goblin Tools Pro. Want to unlock it?'], 979 templates: ['pro.gateTemplates', 'Saving your own templates is part of Goblin Tools Pro. Want to unlock it?'], 980 history: ['pro.gateHistory', 'Keeping your history across the tools is part of Goblin Tools Pro. Want to unlock it?'], 981 formalizerTones: ['pro.gateFormalizerTones', 'Saving your own tones is part of Goblin Tools Pro. Want to unlock it?'], 982 formalizerVoice: ['pro.gateFormalizerVoice', 'Rewriting text in your own writing style is part of Goblin Tools Pro. Want to unlock it?'], 983 formalizerCompare: ['pro.gateFormalizerCompare', 'Comparing several tones at once is part of Goblin Tools Pro. Want to unlock it?'] 984 }; 985 986 function closeGate() { 987 if (!gateEl) return; 988 989 if (gateOutsideHandler) { 990 document.removeEventListener('click', gateOutsideHandler, true); 991 document.removeEventListener('keydown', gateKeyHandler, true); 992 window.removeEventListener('scroll', closeGate, true); 993 window.removeEventListener('resize', closeGate); 994 gateOutsideHandler = null; 995 } 996 997 if (gateEl.parentNode) gateEl.parentNode.removeChild(gateEl); 998 gateEl = null; 999 1000 var a = gateAnchor; 1001 gateAnchor = null; 1002 // Only return focus if the control is still there and visible â focusing 1003 // a detached or display:none element (a menu item whose dropdown closed) 1004 // is a silent no-op that would strand focus on the body instead. 1005 if (a && a.isConnected && a.offsetParent !== null && typeof a.focus === 'function') { 1006 try { a.focus(); } catch (e) { } 1007 } 1008 } 1009 1010 function gateKeyHandler(e) { 1011 if (e.key === 'Escape') { e.stopPropagation(); closeGate(); } 1012 } 1013
1014 // Place the popover just below the anchor, flipping above when there isn't 1015 // room, and clamped into the viewport. anchorRect is captured up front so a 1016 // control that vanishes right after the click (a menu item closing its 1017 // dropdown) is still positioned correctly. 1018 function positionGate(pop, anchorRect) { 1019 var gap = 8; 1020 var edge = 8; 1021 var pw = pop.offsetWidth; 1022 var ph = pop.offsetHeight; 1023 1024 var top = anchorRect.bottom + gap; 1025 if (top + ph > window.innerHeight - edge) { 1026 var above = anchorRect.top - gap - ph; 1027 top = above >= edge ? above : Math.max(edge, window.innerHeight - ph - edge); 1028 } 1029 1030 var left = anchorRect.left; 1031 if (left + pw > window.innerWidth - edge) left = window.innerWidth - pw - edge; 1032 if (left < edge) left = edge; 1033 1034 pop.style.top = top + 'px'; 1035 pop.style.left = left + 'px'; 1036 } 1037 1038 /** 1039 * The shared anchored-popover core behind the Pro gate and the "set it up in 1040 * your account" nudge: one message line and one CTA button, positioned 1041 * against a control and dismissed the same way everywhere. Falls back to 1042 * running the CTA action directly when there is nothing to anchor to. 1043 */ 1044 function showGatePopover(anchor, messageText, ctaText, ctaAction) { 1045 if (!anchor || !anchor.getBoundingClientRect) { if (ctaAction) ctaAction(); return; } 1046 1047 // Measure before anything can move or remove the anchor. 1048 var anchorRect = anchor.getBoundingClientRect(); 1049 1050 closeGate(); 1051 gateAnchor = anchor; 1052 1053 var pop = document.createElement('div'); 1054 pop.className = 'gt-pro-gate'; 1055 pop.setAttribute('role', 'dialog'); 1056 1057 var close = document.createElement('button'); 1058 close.type = 'button'; 1059 close.className = 'gt-pro-gate-close'; 1060 close.setAttribute('aria-label', i18n('pro.gateClose', 'Close')); 1061 // Static icon markup, no user/API text â safe as innerHTML. 1062 close.innerHTML = '<i class="fas fa-times"></i>'; 1063 close.addEventListener('click', closeGate); 1064 1065 var text = document.createElement('div'); 1066 text.className = 'gt-pro-gate-msg'; 1067 text.id = 'gt-pro-gate-msg-' + (++gateSeq); 1068 // textContent, never innerHTML. 1069 text.textContent = messageText; 1070 // Name the dialog by its message so a screen reader reads it on entry: 1071 // focus lands on the CTA, and the dialog's name gives that button its 1072 // context ("Sharing lists is part of Pro⦠: Get Pro, button"). 1073 pop.setAttribute('aria-labelledby', text.id); 1074 1075 var cta = document.createElement('button'); 1076 cta.type = 'button'; 1077 cta.className = 'gt-pro-gate-cta btn btn-primary btn-sm w-100'; 1078 cta.textContent = ctaText; 1079 cta.addEventListener('click', function () { closeGate(); if (ctaAction) ctaAction(); }); 1080 1081 pop.appendChild(close); 1082 pop.appendChild(text); 1083 pop.appendChild(cta); 1084 document.body.appendChild(pop); 1085 gateEl = pop; 1086 1087 positionGate(pop, anchorRect); 1088 cta.focus(); 1089 1090 // Dismiss on an outside click / Escape / scroll / resize. Attached on the 1091 // NEXT tick so the very click that opened this doesn't immediately close it. 1092 // The gateEl !== pop guard drops a stale timer whose popover was already 1093 // replaced by a newer one, so its listeners are never orphaned. 1094 setTimeout(function () { 1095 if (gateEl !== pop) return; 1096 gateOutsideHandler = function (e) { if (gateEl && !gateEl.contains(e.target)) closeGate(); }; 1097 document.addEventListener('click', gateOutsideHandler, true); 1098 document.addEventListener('keydown', gateKeyHandler, true); 1099 window.addEventListener('scroll', closeGate, true); 1100 window.addEventListener('resize', closeGate); 1101 }, 0); 1102 } 1103 1104 /** 1105 * The upsell gate anchored to a control: "this is part of Pro" + Get Pro. 1106 * Falls back to a plain navigation when there is nothing to anchor to. 1107 */ 1108 window.gtProGate = function (anchor, feature) { 1109 var msg = GATE_MESSAGES[feature] || ['pro.gateGeneric', 'This is part of Goblin Tools Pro. Want to unlock it?']; 1110 showGatePopover(anchor, i18n(msg[0], msg[1]), i18n('pro.gateCta', 'Get Pro'), function () { window.gtProUpsell(); }); 1111 }; 1112 1113 /** 1114 * A general anchored nudge with a caller-supplied message and CTA â same look 1115 * and dismissal as the gate. Used for "you're subscribed, but you still need 1116 * to set this up in your account" prompts. 1117 */ 1118 window.gtProNudge = function (anchor, messageText, ctaText, ctaAction) { 1119 showGatePopover(anchor, messageText, ctaText, ctaAction); 1120 }; 1121 1122 // The grace-bar "renew" button. A lapsed-but-in-grace subscriber is signed 1123 // in, so this is always the upgrade page (which does not bounce them, 1124 // because the bounce is gated on pro && !inGrace). 1125 window.gtProStartCheckout = function () { 1126 window.location.href = '/pro/upgrade'; 1127 }; 1128 1129 /** 1130 * The header Pro button, everywhere. Three destinations from the hint 1131 * cookie (no async): a subscriber to their account, a signed-in 1132 * non-subscriber to checkout, a signed-out visitor to the sign-in page. 1133 */ 1134 function onProButtonClick(e) { 1135 e.preventDefault(); 1136 1137 // Signed in (subscribed or not): the account page, where they manage the 1138 // account and can sign out. Signed out: the Pro sign-in / pitch page. 1139 if (GT.signedIn) { 1140 // The button is in the header of the settings page too, where 1141 // following it would just reload what they are already looking at. 1142 if (window.location.pathname.toLowerCase() !== '/account') { 1143 window.location.href = '/Account'; 1144 } 1145 return; 1146 } 1147 1148 window.location.href = '/pro'; 1149 } 1150 1151 // ---- Announcement banner ------------------------------------------------ 1152 // A desktop-only bar (rendered in _Layout for anyone who isn't a 1153 // subscriber). Its behaviour depends on sign-in state, refined live once the 1154 // session check confirms it: 1155 // signed out â an announcement; the whole bar opens /pro/join, the Ã
1156 // dismisses it for good. 1157 // signed in, no Pro â a standing prompt; the whole bar goes to 1158 // /pro/upgrade, it cannot be dismissed, and it carries 1159 // a sign-out button. 1160 // subscriber â hidden entirely. 1161 1162 function bannerDismissed() { 1163 try { return localStorage.getItem('gtProBannerDismissed') === '1'; } 1164 catch (e) { return false; } 1165 } 1166 1167 function refreshProBanner() { 1168 var b = document.getElementById('gt-pro-banner'); 1169 if (!b) return; 1170 1171 // Never on a Pro page (it would point at the page you are already on), 1172 // and never for a subscriber. 1173 if (onProPage() || GT.pro) { 1174 b.classList.add('gt-pro-banner-hidden'); 1175 return; 1176 } 1177 1178 var signedIn = !!GT.signedIn; 1179 1180 // Signed-in prompt is not dismissible; the announcement is. 1181 b.classList.toggle('gt-pro-banner-hidden', !signedIn && bannerDismissed()); 1182 1183 var msg = document.getElementById('gt-pro-banner-msg'); 1184 if (msg && signedIn) msg.textContent = i18n('pro.bannerSignedIn', "You're signed in. Subscribe to unlock Pro."); 1185 1186 var cta = b.querySelector('.gt-pro-banner-cta'); 1187 if (cta && signedIn) cta.textContent = i18n('pro.subscribeCta', 'Go to checkout'); 1188 1189 // The à only makes sense for the dismissible announcement. 1190 var close = b.querySelector('.gt-pro-banner-close'); 1191 if (close) close.style.display = signedIn ? 'none' : ''; 1192 1193 // The sign-out button only for the signed-in prompt. 1194 var signout = b.querySelector('.gt-pro-banner-signout'); 1195 if (signout) signout.style.display = signedIn ? '' : 'none'; 1196 } 1197 1198 // Whole-bar click: the create-account page for a signed-out visitor, checkout 1199 // for a signed-in non-subscriber â the same destinations as every other Pro 1200 // CTA (see upsellUrl). 1201 window.gtProBannerClick = function () { 1202 window.location.href = upsellUrl(); 1203 }; 1204 1205 window.gtProBannerDismiss = function (e) { 1206 if (e) e.stopPropagation(); // the à must not also open the bar 1207 if (GT.signedIn) return; // the signed-in prompt is standing 1208 try { localStorage.setItem('gtProBannerDismissed', '1'); } catch (err) { } 1209 refreshProBanner(); 1210 }; 1211 1212 // The banner's sign-out button. stopPropagation so it doesn't also fire the 1213 // whole-bar click. 1214 window.gtProBannerSignOut = function (e) { 1215 if (e) e.stopPropagation(); 1216 window.gtProSignOut(); 1217 }; 1218 1219 // ---- Session hints ------------------------------------------------------ 1220 1221 /** 1222 * Clerk sets a "__client_uat" cookie on the registrable domain 1223 * (.goblin.tools) as a lightweight, first-party signal of session state: a 1224 * positive Unix timestamp while a session exists, "0" once signed out. We 1225 * read it to decide whether ClerkJS needs loading at all on an ordinary page. 1226 * 1227 * Our own gt_pro hint cookie is the primary signal, but it is written only 1228 * AFTER a session check runs â so straight after a social login, when the 1229 * provider bounces the visitor back, our cookie is not there yet. An 1230 * anonymous visitor has neither cookie, so they still download nothing. 1231 */ 1232 function clerkSessionLikely() { 1233 return document.cookie.split(';').some(function (c) { 1234 var t = c.trim(); 1235 if (t.indexOf('__client_uat') !== 0) return false; 1236 var v = t.slice(t.indexOf('=') + 1); 1237 return v && v !== '0'; 1238 }); 1239 } 1240 1241 /** Our hint cookie OR Clerk's own cookie says a session probably exists. */ 1242 function hasSessionHint() { 1243 return GT.signedIn || clerkSessionLikely(); 1244 } 1245 1246 // ---- Wiring ------------------------------------------------------------- 1247 1248 document.addEventListener('DOMContentLoaded', function () { 1249 document.querySelectorAll('.gt-pro-btn').forEach(function (btn) { 1250 btn.addEventListener('click', onProButtonClick); 1251 }); 1252 1253 // Settle the announcement banner on every page â including the Pro pages, 1254 // where it hides itself (it would otherwise point at the page you are 1255 // already on) before the page-specific init returns. 1256 refreshProBanner(); 1257 1258 var page = proPage(); 1259 1260 if (page === 'pro') { initAuthPage('signin'); return; } 1261 if (page === 'join') { initAuthPage('signup'); return; } 1262 if (page === 'upgrade') { initUpgradePage(); return; } 1263 1264 // ---- Ordinary page ---- 1265 // Reveal the signed-in controls and settle the banner straight away from 1266 // the hint cookie, so nothing flashes in after the async session check. 1267 setSignedInControlsVisible(GT.signedIn); 1268 refreshProBanner(); 1269 1270 // A session probably exists (our hint cookie, or Clerk's own cookie 1271 // right after a social login before ours is written). Load ClerkJS and 1272 // check: onClerkChange / refreshSession then reflect Pro chrome, the 1273 // banner and the grace bar. An anonymous visitor loads nothing. 1274 if (hasSessionHint()) { 1275 loadClerk().then(refreshSession).catch(function () { }); 1276 } 1277 }); 1278 1279 // ---- Public surface ----------------------------------------------------- 1280
1281 window.GoblinPro = { 1282 authToken: authToken, 1283 fetch: authFetch, 1284 refreshSession: refreshSession, 1285 isPro: function () { return !!(session && session.pro); }, 1286 isSignedIn: function () { return !!(session && session.signedIn); }, 1287 session: function () { return session; }, 1288 ready: loadClerk 1289 }; 1290})();
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.