PageSourceSearch

https://goblin.tools/js/pro-auth.js?v=8JObYbpS9xD-ujZjMQ2w7c-oVgsZE1MHe-47Ap7BPdo

js goblin.tools collected 2026-10-01 11:36:49 UTC 57,802 bytes, 1,290 lines download raw bytes

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.