PageSourceSearch

https://curiochat.ai/account/

html curiochat.ai collected 2026-10-05 15:00:55 UTC 43,498 bytes, 854 lines download raw bytes

1<!DOCTYPE html>
2<html lang="en" class="h-full scroll-smooth bg-mist/20 antialiased">
3<head>
4    <!-- Google Tag Manager -->
5    
5<script>
vendor: 455 bytes, lines 5-15
5
6      (function (w, d, s, l, i) {
7        w[l] = w[l] || [];
8        w[l].push({ "gtm.start": new Date().getTime(), event: "gtm.js" });
9        var f = d.getElementsByTagName(s)[0],
10          j = d.createElement(s),
11          dl = l != "dataLayer" ? "&l=" + l : "";
12        j.async = true;
13        j.src = "https://www.googletagmanager.com/gtm.js?id=" + i + dl;
14        f.parentNode.insertBefore(j, f);
15      })(window, document, "script", "dataLayer", "
15GTM-PV2L8Z4L
vendor: 8 bytes, lines 15-16
15");
16    
16</script>
16
17    <!-- End Google Tag Manager -->
18
19    
20    <meta charset="utf-8">
21    <meta name="viewport" content="width=device-width, initial-scale=1">
22    <meta name="robots" content="noindex, nofollow">
23    <title>Your Account | CurioChat.ai</title>
24    <meta name="author" content="Pierre Boutquin">
25
26    <!-- Favicons -->
27    <link rel="icon" href="/assets/favicon.ico">
28    <link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png">
29    <link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png">
30    <link rel="manifest" href="/manifest.json">
31    <meta name="theme-color" content="#0D3B66">
32    <!-- Google Fonts -->
33<link rel="preconnect" href="https://fonts.googleapis.com">
34<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
35<!-- Loaded non-render-blocking: media=print flips to all on load (FOUT covered by display=swap); noscript fallback for no-JS. -->
36<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@600;700;800;900&family=Open+Sans:wght@400;600;700&display=swap" media="print" onload="this.media='all'">
37<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@600;700;800;900&family=Open+Sans:wght@400;600;700&display=swap"></noscript>
38
39<!-- Tailwind v4 native build (npm run build → src/css/curiochat-tw.css) -->
40<link rel="stylesheet" href="/css/curiochat-tw.css?v=20260928a">
41</head>
42<body class="flex min-h-full flex-col text-ink antialiased">
43    <!-- Google Tag Manager (noscript) -->
vendor: 72 bytes, lines 43-44
43
44    <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=
44GTM-PV2L8Z4L
vendor: 91 bytes, lines 44-46
44"
45    height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
46    
46<!-- End Google Tag Manager (noscript) -->
47
48    <a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-navy focus:px-4 focus:py-2 focus:font-semibold focus:text-white focus:shadow-lg">Skip to main content</a>
49
50    <header class="sticky top-0 z-40 border-b border-gray-200 bg-white/95 backdrop-blur">
51      <nav aria-label="Account navigation" class="mx-auto flex max-w-7xl items-center justify-between gap-4 px-6 py-3 lg:px-8">
52        <div class="flex items-center gap-x-4">
53          <a href="/" aria-label="CurioChat.ai Home" class="flex items-center">
54            <img src="/assets/logo_800x160_transparent.svg" alt="CurioChat.ai" class="h-7 w-auto" />
55          </a>
56          <span class="hidden text-slate/40 sm:inline">/</span>
57          <span class="hidden font-mono text-xs font-semibold uppercase tracking-widest text-navy sm:inline">Account</span>
58        </div>
59        <div class="flex items-center gap-x-5 text-sm">
60          <a href="mailto:[email protected]" class="hidden text-slate hover:text-ink md:inline-block">Support</a>
61          <!-- Hidden until verify succeeds: signing out of a session you have
62               not signed into is a dead control on the email gate. -->
63          <a href="/account/?logout=1" id="sign-out-link" hidden class="inline-flex items-center rounded-full bg-ink px-3 py-1 text-sm font-semibold text-white hover:bg-graphite">Sign Out</a>
64        </div>
65      </nav>
66    </header>
67
68    <main id="main" class="flex-auto">
69
70      <!-- =====================================================================
71           VERIFY SECTION — email-gate. Centered card on warm-cream backdrop.
72           Always rendered; JS hides this and shows #account-content on success.
73           ===================================================================== -->
74      <section id="verify-section" class="relative">
75        <!-- Soft warm-cream backdrop. -->
76        <div class="absolute inset-0 -z-10 overflow-hidden">
77          <div class="absolute inset-0 bg-linear-to-b from-warm-soft via-warm-soft-mid/40 to-white"></div>
78          <div class="absolute inset-0 bg-[radial-gradient(ellipse_at_50%_0%,rgba(244,162,97,0.12),transparent_60%)]"></div>
79        </div>
80
81        <div class="flex min-h-[70vh] flex-col justify-center px-6 py-16 lg:px-8 lg:py-20">
82          <div class="mx-auto w-full max-w-sm text-center">
83            <h1 class="font-display text-3xl font-bold tracking-tight text-ink sm:text-4xl">Your account.</h1>
84            <p class="mt-3 text-sm text-slate">Enter your email to access your account.</p>
85          </div>
86
87          <div class="mx-auto mt-10 w-full max-w-sm">
88            <div class="rounded-2xl bg-white p-8 shadow-xl ring-1 ring-gray-200">
89              <div id="error-message" class="mb-5 hidden rounded-md bg-rust/10 px-4 py-3 text-sm text-rust-dark ring-1 ring-rust/20"></div>
90
91              <form id="verify-form" onsubmit="return handleVerify(event);" class="space-y-5">
92                <div>
93                  <label for="email-input" class="block text-sm font-semibold text-ink">Email Address</label>
94                  <input
95                    type="email"
96                    id="email-input"
97                    name="email"
98                    required
99                    placeholder="[email protected]"
100                    value=""
101                    autocomplete="email"
102                    pattern="[^@\s]+@[^@\s]+\.[^@\s]+"
103                    class="mt-2 block w-full rounded-[8px] bg-white px-4 py-3 text-base text-ink shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)] ring-1 ring-inset ring-gray-300 focus:outline-none focus:ring-2 focus:ring-warm"
104                  >
105                </div>
106
107                <div class="flex items-center gap-x-3">
108                  <input type="checkbox" id="remember-me" name="remember" value="1" checked class="size-4 rounded border-gray-300 text-warm focus:ring-warm">
109                  <label for="remember-me" class="text-sm text-graphite">Remember me on this device</label>
110                </div>
111
112                <button type="submit" id="verify-btn" class="block min-h-[44px] w-full rounded-[8px] border-2 border-warm bg-warm px-7 py-3 font-display text-base font-bold text-navy shadow-md transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy focus-visible:ring-offset-2 hover:border-warm-hover hover:bg-warm-hover hover:shadow-lg">
113                  Access Account
114                </button>
115              </form>
116
117              <p class="mt-6 text-center text-sm text-slate">
118                Need help? <a href="mailto:[email protected]" class="font-semibold text-navy hover:underline">Contact support</a>
119              </p>
120            </div>
121          </div>
122        </div>
123      </section>
124
125      <!-- =====================================================================
126           ACCOUNT CONTENT — post-verify dashboard. Hidden until JS reveals it.
127           ===================================================================== -->
128      <section id="account-content" style="display: none;">
129
130        
131        <!-- Welcome Banner -->
132        <section class="py-10 lg:py-12">
133          <div class="mx-auto max-w-4xl px-6 lg:px-8">
134            <div class="flex flex-col gap-6 sm:flex-row sm:items-center">
135              <div id="member-avatar" aria-hidden="true" class="flex size-16 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-navy to-hero-gradient-mid font-display text-2xl font-bold text-amber">?</div>
136              <div class="flex-1">
137                <h1 class="font-display text-3xl font-bold tracking-tight text-ink sm:text-4xl">Welcome, <span id="member-name">there</span>!</h1>
138                <p class="mt-2 text-sm leading-relaxed text-slate">Your courses, your packs, and what lands in your inbox.</p>
139              </div>
140            </div>
141          </div>
142        </section>
143
144        <!-- Your Courses -->
145        <section aria-labelledby="courses-heading" class="pb-12">
146          <div class="mx-auto max-w-4xl px-6 lg:px-8">
147            <h2 id="courses-heading" class="font-mono text-xs font-semibold uppercase tracking-widest text-slate">Your courses</h2>
148            <p class="mt-2 font-display text-2xl font-bold tracking-tight text-ink">Courses &amp; access</p>
149
150            <div id="products-grid" class="mt-8 grid grid-cols-1 gap-6">
151              <!-- Populated by JavaScript -->
152            </div>
153          </div>
154        </section>
155
156        <!-- Your Packs — catalog-sourced three-state card grid (M-2). The card
157             SET comes from $packsCatalog (the registry-backed catalog); the
158             client flips owned-vs-locked from subscriberData.packs in
159             renderPacks(). Scaffold-availability is surfaced per card. -->
160        <section aria-labelledby="packs-heading" class="pb-12" data-packs-grid>
161  <div class="mx-auto max-w-4xl px-6 lg:px-8">
162    <h2 id="packs-heading" class="font-mono text-xs font-semibold uppercase tracking-widest text-slate">Your packs</h2>
163    <p class="mt-2 font-display text-2xl font-bold tracking-tight text-ink">Skill packs &amp; onboarding</p>
164
165    <!-- Degraded state: shown by the hub's client JS only when verify.php is
166         unavailable. Conveys the failure in TEXT (AC-11, AC-17). -->
167    <div
168      id="packs-degraded"
169      role="status"
170      class="mt-6 hidden rounded-lg bg-amber/15 px-4 py-3 text-sm text-amber-deep ring-1 ring-amber/30"
171    >
172      We couldn't load your packs right now.
173      <button type="button" id="packs-retry-btn" class="font-semibold underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warm">
174        Retry
175      </button>
176    </div>
177
178    <ul role="list" class="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2" data-packs-list>
179                      <li
180          class="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-gray-100"
181          data-pack-slug="meta-pack"
182          data-pack-state="locked"
183          data-scaffold-available="false"
184        >
185          <div class="flex h-full flex-col gap-y-4 p-6">
186            <div class="grow">
187              <h3 class="font-display text-base font-bold text-ink">CurioChat Foundation Pack</h3>
188              <!-- State label — TEXT-conveyed (AC-17). The client swaps the
189                   locked label for the owned label; scaffold-missing keeps the
190                   "Available soon" note regardless. -->
191              <p class="mt-1 text-xs text-slate" data-pack-status-label>
192                <span data-pack-locked-label>Not yet unlocked</span>
193                <span data-pack-owned-label hidden>&#10003; Owned</span>
194                                  <span class="block text-amber-deep" data-pack-scaffold-note>Available soon &mdash; onboarding for this pack is being prepared.</span>
195                              </p>
196            </div>
197
198            <div class="flex flex-wrap items-center gap-3">
199              <!-- LOCKED affordance (default): sales link. Client hides this
200                   when the card is owned. Held back (TEXT note instead) while
201                   the pack's sales page hasn't shipped — packSalesPageLive()
202                   revives the link automatically once the page deploys. -->
203                            <span
204                data-pack-sales-link
205                data-pack-sales-pending
206                                class="text-sm text-slate"
207              >
208                Coming soon &mdash; not yet available for purchase.
209              </span>
210              
211              <!-- OWNED affordances (the client flips visibility once the async
212                   verify resolves): onboarding link + the gated Download. -->
213              <a
214                href="/account/packs/meta-pack/"
215                data-pack-onboarding-link
216                hidden                class="inline-flex min-h-[44px] items-center justify-center gap-x-2 rounded-[8px] border-2 border-navy bg-navy px-5 py-2 font-display text-sm font-bold text-white shadow-md transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy focus-visible:ring-offset-2 hover:border-navy-hover hover:bg-navy-hover hover:shadow-lg"
217              >
218                Open onboarding &rarr;
219              </a>
220              <a
221                href="/api/cca/v1/download?pack=meta-pack"
222                data-pack-download-link
223                hidden                aria-label="Download CurioChat Foundation Pack"
224                class="inline-flex min-h-[44px] items-center justify-center gap-x-2 rounded-[8px] border-2 border-gray-300 bg-white px-5 py-2 font-display text-sm font-bold text-navy shadow-sm transition hover:bg-mist/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warm"
225              >
226                Download
227              </a>
228            </div>
229          </div>
230        </li>
231                      <li
232          class="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-gray-100"
233          data-pack-slug="vault-pack"
234          data-pack-state="locked"
235          data-scaffold-available="false"
236        >
237          <div class="flex h-full flex-col gap-y-4 p-6">
238            <div class="grow">
239              <h3 class="font-display text-base font-bold text-ink">CurioChat Vault Pack</h3>
240              <!-- State label — TEXT-conveyed (AC-17). The client swaps the
241                   locked label for the owned label; scaffold-missing keeps the
242                   "Available soon" note regardless. -->
243              <p class="mt-1 text-xs text-slate" data-pack-status-label>
244                <span data-pack-locked-label>Not yet unlocked</span>
245                <span data-pack-owned-label hidden>&#10003; Owned</span>
246                                  <span class="block text-amber-deep" data-pack-scaffold-note>Available soon &mdash; onboarding for this pack is being prepared.</span>
247                              </p>
248            </div>
249
250            <div class="flex flex-wrap items-center gap-3">
251              <!-- LOCKED affordance (default): sales link. Client hides this
252                   when the card is owned. Held back (TEXT note instead) while
253                   the pack's sales page hasn't shipped — packSalesPageLive()
254                   revives the link automatically once the page deploys. -->
255                            <span
256                data-pack-sales-link
257                data-pack-sales-pending
258                                class="text-sm text-slate"
259              >
260                Coming soon &mdash; not yet available for purchase.
261              </span>
262              
263              <!-- OWNED affordances (the client flips visibility once the async
264                   verify resolves): onboarding link + the gated Download. -->
265              <a
266                href="/account/packs/vault-pack/"
267                data-pack-onboarding-link
268                hidden                class="inline-flex min-h-[44px] items-center justify-center gap-x-2 rounded-[8px] border-2 border-navy bg-navy px-5 py-2 font-display text-sm font-bold text-white shadow-md transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy focus-visible:ring-offset-2 hover:border-navy-hover hover:bg-navy-hover hover:shadow-lg"
269              >
270                Open onboarding &rarr;
271              </a>
272              <a
273                href="/api/cca/v1/download?pack=vault-pack"
274                data-pack-download-link
275                hidden                aria-label="Download CurioChat Vault Pack"
276                class="inline-flex min-h-[44px] items-center justify-center gap-x-2 rounded-[8px] border-2 border-gray-300 bg-white px-5 py-2 font-display text-sm font-bold text-navy shadow-sm transition hover:bg-mist/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warm"
277              >
278                Download
279              </a>
280            </div>
281          </div>
282        </li>
283          </ul>
284  </div>
285</section>
286
287        <!-- Email preferences — ONE card, one row per newsletter, ONE save.
288             Previously two stacked cards, each with its own identically
289             labelled "Save Preferences" button, and the shared "you'll always
290             receive" note buried inside the first card below its CTA. A member
291             changing both subscriptions had to save twice and read two toasts,
292             and the second newsletter read as an afterthought. The two Kit
293             endpoints stay separate (newsletter.php / ai-newsletter.php); the
294             single save calls only the ones whose value actually changed. -->
295        <section aria-labelledby="prefs-heading" class="pb-16 lg:pb-24">
296          <div class="mx-auto max-w-4xl px-6 lg:px-8">
297            <h2 id="prefs-heading" class="font-mono text-xs font-semibold uppercase tracking-widest text-slate">Preferences</h2>
298            <p class="mt-2 font-display text-2xl font-bold tracking-tight text-ink">Email</p>
299
300            <article class="mt-8 rounded-2xl bg-white p-6 shadow-sm sm:p-8">
301              <form id="email-prefs-form" onsubmit="return handleEmailPrefsSave(event);">
302                <fieldset>
303                  <legend class="sr-only">Newsletter subscriptions</legend>
304
305                  <div class="divide-y divide-gray-200">
306                    <label class="flex cursor-pointer items-start gap-x-4 py-5 first:pt-0">
307                      <input type="checkbox" name="newsletter" id="newsletter-toggle" class="mt-1 size-4 shrink-0 rounded border-gray-300 text-warm focus:ring-warm">
308                      <span class="text-sm">
309                        <strong class="font-display text-base font-bold text-ink">Saturday Reset</strong>
310                        <span class="mt-1 block leading-relaxed text-graphite">Weekly notes on building the infrastructure underneath your AI work. No fluff, no hype.</span>
311                        <span id="status-text" class="mt-1 block text-xs text-slate"></span>
312                      </span>
313                    </label>
314
315                    <label class="flex cursor-pointer items-start gap-x-4 py-5">
316                      <input type="checkbox" name="ai-newsletter" id="ai-newsletter-toggle" class="mt-1 size-4 shrink-0 rounded border-gray-300 text-warm focus:ring-warm">
317                      <span class="text-sm">
318                        <strong class="font-display text-base font-bold text-ink">Build a Business That Learns</strong>
319                        <span class="mt-1 block leading-relaxed text-graphite">The AI newsletter — notes on turning AI into infrastructure for your business, not a novelty.</span>
320                        <span id="ai-newsletter-status-text" class="mt-1 block text-xs text-slate"></span>
321                      </span>
322                    </label>
323                  </div>
324                </fieldset>
325
326                <button type="submit" id="save-newsletter-btn" class="mt-6 inline-flex min-h-[44px] w-full items-center justify-center rounded-[8px] border-2 border-navy bg-navy px-5 py-2 font-display text-sm font-bold text-white shadow-md transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy focus-visible:ring-offset-2 hover:border-navy-hover hover:bg-navy-hover hover:shadow-lg">
327                  Save preferences
328                </button>
329              </form>
330            </article>
331
332            <p class="mt-4 text-sm leading-relaxed text-slate">
333              Unsubscribing here stops the newsletters only. Purchase confirmations, course access emails and important account updates always reach you.
334            </p>
335          </div>
336        </section>
337
338      </section>
339
340    </main>
341
342    <!-- Toast Notification — fixed bottom-right, toggled by JS -->
343    <div id="toast" class="fixed bottom-6 right-6 z-50 hidden rounded-lg px-4 py-3 text-sm font-semibold text-white shadow-lg transition" role="status" aria-l
343ive="polite"></div>
344
345    
345<script>
346    // ========================================================================
347    // State
348    // ========================================================================
349    let verifiedEmail  = '';
350    let subscriberData = null;
351    // Was this a fresh-purchase landing? Used to retry verify across the
352    // ThriveCart-webhook → Kit-tag propagation window (typically <30s).
353    const justPurchased = false;
354    let purchaseRetryCount = 0;
355    const PURCHASE_MAX_RETRIES = 3;
356    const PURCHASE_RETRY_DELAYS_MS = [3000, 5000, 8000];
357
358    // ========================================================================
359    // Utility Functions
360    // ========================================================================
361    function showToast(message, type = 'success') {
362        const toast = document.getElementById('toast');
363        toast.textContent = message;
364        toast.classList.remove('bg-rust-dark', 'bg-ink');
365        toast.classList.add(type === 'error' ? 'bg-rust-dark' : 'bg-ink');
366        toast.classList.remove('hidden');
367        clearTimeout(toast._hideTimer);
368        toast._hideTimer = setTimeout(() => { toast.classList.add('hidden'); }, 3000);
369    }
370
371    function showError(message) {
372        const errorEl = document.getElementById('error-message');
373        errorEl.textContent = message;
374        errorEl.classList.remove('hidden');
375    }
376
377    function hideError() {
378        document.getElementById('error-message').classList.add('hidden');
379    }
380
381    function setButtonLoading(btn, loading) {
382        if (loading) {
383            btn.disabled = true;
384            btn.dataset.originalText = btn.textContent;
385            btn.innerHTML = '<span class="mr-2 inline-block size-4 animate-spin rounded-full border-2 border-current border-r-transparent align-[-0.125em]" role="status" aria-hidden="true"></span>Loading...';
386        } else {
387            btn.disabled = false;
388            btn.textContent = btn.dataset.originalText || 'Submit';
389        }
390    }
391
392    // ========================================================================
393    // Cookie Management
394    // ========================================================================
395    function setRememberCookie(email) {
396        const date = new Date();
397        date.setTime(date.getTime() + (30 * 24 * 60 * 60 * 1000));
398        document.cookie = 'curio_ai_account_email=' + encodeURIComponent(email) +
399            ';expires=' + date.toUTCString() +
400            ';path=/;secure;samesite=strict';
401    }
402
403    function clearRememberCookie() {
404        document.cookie = 'curio_ai_account_email=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;secure;samesite=strict';
405    }
406
407    // ========================================================================
408    // API Calls
409    // ========================================================================
410    async function verifyAccount(email) {
411        const response = await fetch('/api/account/verify.php', {
412            method: 'POST',
413            headers: { 'Content-Type': 'application/json' },
414            body: JSON.stringify({ email })
415        });
416        return await response.json();
417    }
418
419    async function updateNewsletter(email, status) {
420        const response = await fetch('/api/account/newsletter.php', {
421            method: 'POST',
422            headers: { 'Content-Type': 'application/json' },
423            body: JSON.stringify({ email, status })
424        });
425        return await response.json();
426    }
427
428    async function updateAiNewsletter(email, status) {
429        const response = await fetch('/api/account/ai-newsletter.php', {
430            method: 'POST',
431            headers: { 'Content-Type': 'application/json' },
432            body: JSON.stringify({ email, status })
433        });
434        return await response.json();
435    }
436
437    // ========================================================================
438    // Form Handlers
439    // ========================================================================
440    async function handleVerify(event) {
441        event.preventDefault();
442
443        const email      = document.getElementById('email-input').value.trim().toLowerCase();
444        const rememberMe = document.getElementById('remember-me').checked;
445        const btn        = document.getElementById('verify-btn');
446
447        if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
448            showError('Please enter a valid email address.');
449            return false;
450        }
451
452        hideError();
453        setButtonLoading(btn, true);
454
455        try {
456            const result = await verifyAccount(email);
457
458            if (result.success) {
459                verifiedEmail  = email;
460                subscriberData = result.subscriber;
461
462                if (rememberMe) {
463                    setRememberCookie(email);
464                } else {
465                    clearRememberCookie();
466                }
467
468                showAccountContent();
469            } else {
470                let errorMsg = result.error || 'Verification failed.';
471                if (result.code) {
472                    errorMsg += ' (' + result.code + ')';
473                }
474                if (result.code === 'HUB-001') {
475                    // Fresh-purchase race: ThriveCart redirected here before its
476                    // native Kit integration finished tagging the subscriber. Retry
477                    // a few times with backoff before falling back to a friendly
478                    // "still provisioning" message. Only triggers when the URL
479                    // says ?welcomed=… so manual logins still get the standard
480                    // "couldn't find that email" message immediately.
481                    if (justPurchased && purchaseRetryCount < PURCHASE_MAX_RETRIES) {
482                        const delay = PURCHASE_RETRY_DELAYS_MS[purchaseRetryCount] || 5000;
483                        purchaseRetryCount++;
484                        showError(`Your access is being provisioned. Retrying in ${delay / 1000}s… (attempt ${purchaseRetryCount}/${PURCHASE_MAX_RETRIES})`);
485                        setButtonLoading(btn, false);
486                        setTimeout(() => { btn.click(); }, delay);
487                        return false;
488                    }
489                    errorMsg = justPurchased
490                        ? "Your purchase is still being provisioned. This usually takes under a minute. Click 'Access Account' to retry, or email [email protected] if it persists."
491                        : "We couldn't find that email. Did you use a different one?";
492                }
493                showError(errorMsg);
494            }
495        } catch (error) {
496            console.error('Verification error:', error);
497            showError('Unable to connect. Please try again. (HUB-002)');
498        }
499
500        setButtonLoading(btn, false);
501        return false;
502    }
503
504    // One save for both newsletters. Each still writes through its own Kit
505    // endpoint, but only the toggles whose value actually changed are sent, so
506    // saving with nothing touched costs no API calls and a member who changed
507    // both gets one confirmation instead of two.
508    async function handleEmailPrefsSave(event) {
509        event.preventDefault();
510
511        const btn = document.getElementById('save-newsletter-btn');
512
513        const writes = [
514            {
515                key:     'newsletter',
516                label:   'Saturday Reset',
517                desired: document.getElementById('newsletter-toggle').checked ? 'active' : 'inactive',
518                current: subscriberData.newsletter || 'active',
519                send:    updateNewsletter,
520                refresh: updateNewsletterDisplay
521            },
522            {
523                key:     'ai_newsletter',
524                label:   'Build a Business That Learns',
525                desired: document.getElementById('ai-newsletter-toggle').checked ? 'active' : 'inactive',
526                current: subscriberData.ai_newsletter || 'inactive',
527                send:    updateAiNewsletter,
528                refresh: updateAiNewsletterDisplay
529            }
530        ].filter(w => w.desired !== w.current);
531
532        if (writes.length === 0) {
533            showToast('No changes to save.');
534            return false;
535        }
536
537        setButtonLoading(btn, true);
538
539        const failed = [];
540
541        for (const write of writes) {
542            try {
543                const result = await write.send(verifiedEmail, write.desired);
544                if (result.success) {
545                    subscriberData[write.key] = result.status;
546                } else {
547                    failed.push(write.label);
548                }
549            } catch (error) {
550                console.error('Newsletter update error:', error);
551                failed.push(write.label);
552            }
553            write.refresh();
554        }
555
556        if (failed.length === 0) {
557            showToast('Preferences saved.');
558        } else {
559            showToast(`Couldn't save ${failed.join(' or ')}. Please try again. (HUB-004)`, 'error');
560        }
561
562        setButtonLoading(btn, false);
563        return false;
564    }
565
566    // ========================================================================
567    // Display Functions
568    // ========================================================================
569    function showAccountContent() {
570        document.getElementById('verify-section').style.display    = 'none';
571        document.getElementById('account-content').style.display   = 'block';
572        document.getElementById('sign-out-link').hidden            = false;
573
574        const firstName = subscriberData.first_name || 'there';
575        document.getElementById('member-name').textContent   = firstName;
576        document.getElementById('member-avatar').textContent = firstName.charAt(0).toUpperCase();
577
578        // If this was a fresh purchase, append ?email= to the just-purchased CTAs
579        // so the buyer doesn't have to re-enter their email on the course portal.
580        // The bundle variant has a primary + secondary CTA; other variants only
581        // expose the primary.
582        ['just-purchased-cta', 'just-purchased-cta-secondary'].forEach((ctaId) => {
583            const cta = document.getElementById(ctaId);
584            if (cta && verifiedEmail) {
585                const url = cta.getAttribute('href');
586                const sep = url.indexOf('?') === -1 ? '?' : '&';
587                cta.setAttribute('href', url + sep + 'email=' + encodeURIComponent(verifiedEmail));
588            }
589        });
590
591        renderProducts();
592        renderPacks();
593        updateNewsletterDisplay();
594        updateAiNewsletterDisplay();
595    }
596
597    // ========================================================================
598    // Your Packs — flip catalog cards owned↔locked from verify.packs (AC-1),
599    // degrade gracefully if the packs slice is missing/malformed (AC-11).
600    //
601    // The card SET is already server-rendered from the catalog (no static
602    // list — AC-13). Here we only flip state: a card is OWNED iff its
603    // data-pack-slug is present in subscriberData.packs. The owned-iff-tag
604    // decision mirrors the PHP partial's packsCardState().
605    // ========================================================================
606    function renderPacks() {
607        const section = document.querySelector('[data-packs-grid]');
608        if (!section) {
609            return; // No packs grid on this page — nothing to do.
610        }
611        const degraded = document.getElementById('packs-degraded');
612
613        // Defensive: a malformed/absent packs slice must not crash the
614        // dashboard. Treat it as "no packs owned" and surface the degraded
615        // notice (AC-11) rather than throwing.
616        const packs = (subscriberData && Array.isArray(subscriberData.packs))
617            ? subscriberData.packs
618            : null;
619
620        if (packs === null) {
621            if (degraded) {
622                degraded.classList.remove('hidden');
623            }
624            // Every card stays locked (its server-rendered default) — no blank
625            // grid, no crash.
626            return;
627        }
628
629        if (degraded) {
630            degraded.classList.add('hidden');
631        }
632
633        const ownedSlugs = new Set(
634            packs
635                .map((p) => (p && typeof p.slug === 'string') ? p.slug : null)
636                .filter((s) => s !== null)
637        );
638
639        section.querySelectorAll('[data-pack-slug]').forEach((card) => {
640            const slug    = card.getAttribute('data-pack-slug');
641            const isOwned = ownedSlugs.has(slug);
642            card.setAttribute('data-pack-state', isOwned ? 'owned' : 'locked');
643
644            const toggle = (sel, show) => {
645                const el = card.querySelector(sel);
646                if (el) { el.hidden = !show; }
647            };
648            // Locked affordance (sales) vs owned affordances (onboarding + download).
649            toggle('[data-pack-sales-link]', !isOwned);
650            toggle('[data-pack-onboarding-link]', isOwned);
651            toggle('[data-pack-download-link]', isOwned);
652            toggle('[data-pack-locked-label]', !isOwned);
653            toggle('[data-pack-owned-label]', isOwned);
654
655            // Owned download deep-links continue the email so the gated
656            // endpoint can resolve entitlement without a re-prompt.
657            if (isOwned && verifiedEmail) {
658                const dl = card.querySelector('[data-pack-download-l
658ink]');
659                if (dl) {
660                    const url = dl.getAttribute('href');
661                    if (url && url.indexOf('email=') === -1) {
662                        const sep = url.indexOf('?') === -1 ? '?' : '&';
663                        dl.setAttribute('href', url + sep + 'email=' + encodeURIComponent(verifiedEmail));
664                    }
665                }
666            }
667        });
668    }
669
670    // Retry button in the degraded panel re-runs verify for the known email.
671    document.addEventListener('DOMContentLoaded', () => {
672        const retry = document.getElementById('packs-retry-btn');
673        if (retry) {
674            retry.addEventListener('click', () => {
675                if (verifiedEmail) {
676                    document.getElementById('email-input').value = verifiedEmail;
677                    document.getElementById('verify-btn').click();
678                }
679            });
680        }
681    });
682
683    function renderProducts() {
684        const grid      = document.getElementById('products-grid');
685        const purchases = subscriberData.purchases || {};
686
687        const products = [
688            {
689                id:             'agentic-coding',
690                name:           'Agentic Coding Mastery',
691                icon:           '⚡',
692                accessUrl:      '/software-engineer/learn/',
693                salesUrl:       '/software-engineer/',
694                deliverablesUrl:'/software-engineer/learn/demonstrations.php'
695            },
696            {
697                id:             'ai-powered-solopreneur',
698                name:           'AI-Powered Solopreneur',
699                icon:           '🚀',
700                accessUrl:      '/solopreneur/learn/',
701                salesUrl:       '/solopreneur/ai-powered/'
702            },
703            {
704                id:             'stay-sharp',
705                name:           'Stay Sharp',
706                icon:           '🧠',
707                accessUrl:      '/learn/stay-sharp/',
708                salesUrl:       '/solopreneur/stay-sharp/'
709            }
710        ];
711
712        let html = '';
713
714        products.forEach(product => {
715            const purchased   = purchases[product.id];
716            const isPurchased = !!purchased;
717
718            // Status chip text. Deliberately no ✅ — a screen reader announces
719            // that as "white heavy check mark", and the chip's colour already
720            // carries the same signal for sighted readers.
721            let statusLabel;
722            if (typeof purchased === 'string') {
723                statusLabel = purchased === 'mastery' ? 'Mastery' : 'Foundation';
724            } else if (product.comingSoon) {
725                statusLabel = isPurchased ? 'Purchased — launching soon' : 'Coming soon';
726            } else {
727                statusLabel = isPurchased ? 'Purchased' : 'Not yet purchased';
728            }
729
730            // Foundation tier gets an upgrade link after the status
731            let upgradeLink = '';
732            if (typeof purchased === 'string' && purchased === 'foundation') {
733                upgradeLink = '<a href="/software-engineer/upgrade.php" class="font-semibold text-rust-dark hover:underline">Upgrade &rarr;</a>';
734            }
735
736            // Action button — disabled placeholder for pre-launch products
737            let actionHtml;
738            if (product.comingSoon) {
739                actionHtml = '<span class="inline-flex min-h-[44px] cursor-default items-center justify-center rounded-[8px] border-2 border-gray-300 bg-white px-5 py-2 font-display text-sm font-bold text-slate opacity-60">Coming soon</span>';
740            } else {
741                let actionUrl = isPurchased ? product.accessUrl : product.salesUrl;
742                if (isPurchased && verifiedEmail) {
743                    actionUrl += '?email=' + encodeURIComponent(verifiedEmail);
744                }
745                const actionLabel = isPurchased ? 'Go to Course &rarr;' : 'Learn more &rarr;';
746                const btnClass    = isPurchased
747                    ? 'inline-flex min-h-[44px] items-center justify-center gap-x-2 rounded-[8px] border-2 border-navy bg-navy px-5 py-2 font-display text-sm font-bold text-white shadow-md transition hover:border-navy-hover hover:bg-navy-hover hover:shadow-lg'
748                    : 'inline-flex min-h-[44px] items-center justify-center gap-x-2 rounded-[8px] border-2 border-warm bg-warm px-5 py-2 font-display text-sm font-bold text-navy shadow-md transition hover:border-warm-hover hover:bg-warm-hover hover:shadow-lg';
749                actionHtml = `<a href="${actionUrl}" class="${btnClass}">${actionLabel}</a>`;
750            }
751
752            // Optional secondary link (e.g., saved demonstrations for Agentic Coding)
753            let extraLink = '';
754            if (isPurchased && product.deliverablesUrl) {
755                let demoUrl = product.deliverablesUrl;
756                if (verifiedEmail) {
757                    demoUrl += '?email=' + encodeURIComponent(verifiedEmail);
758                }
759                extraLink = `<a href="${demoUrl}" class="font-semibold text-navy hover:underline">Your demonstrations</a>`;
760            }
761
762            const chipClass = isPurchased
763                ? 'inline-flex items-center rounded-full bg-teal-deep/10 px-2.5 py-0.5 text-xs font-semibold text-teal-deep'
764                : 'inline-flex items-center rounded-full bg-mist px-2.5 py-0.5 text-xs font-semibold text-slate';
765
766            // One row, one background. The card used to be a 2/5 + 3/5 grid
767            // with the purchased tint on the left cell only, which drew a seam
768            // down the middle and squeezed the title into 40% of the width
769            // while the button floated in 60% of empty space.
770            html += `
771              <article class="overflow-hidden rounded-2xl ${isPurchased ? 'bg-mist/40' : 'bg-white'} shadow-sm">
772                <div class="flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:gap-6">
773                  <div class="flex min-w-0 flex-1 items-center gap-x-4">
774                    <span class="text-3xl leading-none" aria-hidden="true">${product.icon}</span>
775                    <div class="min-w-0">
776                      <p class="font-display text-base font-bold text-ink">${product.name}</p>
777                      <p class="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate">
778                        <span class="${chipClass}">${statusLabel}</span>${upgradeLink}${extraLink}
779                      </p>
780                    </div>
781                  </div>
782                  <div class="shrink-0">
783                    ${actionHtml}
784                  </div>
785                </div>
786              </article>
787            `;
788        });
789
790        grid.innerHTML = html;
791    }
792
793    function updateNewsletterDisplay() {
794        const isActive = ((subscriberData.newsletter || 'active') === 'active');
795
796        document.getElementById('status-text').textContent = isActive
797            ? 'Subscribed — arrives Saturday mornings.'
798            : 'Not subscribed.';
799
800        document.getElementById('newsletter-toggle').checked = isActive;
801    }
802
803    function updateAiNewsletterDisplay() {
804        const isActive = ((subscriberData.ai_newsletter || 'inactive') === 'active');
805
806        document.getElementById('ai-newsletter-status-text').textContent = isActive
807            ? 'Subscribed.'
808            : 'Not subscribed.';
809
810        document.getElementById('ai-newsletter-toggle').checked = isActive;
811    }
812
813    // ========================================================================
814    // Auto-verify on page load if email provided
815    // ========================================================================
816    const emailInput = document.getElementById('email-input');
817    if (emailInput.value) {
818        setTimeout(() => { document.getElementById('verify-btn').click(); }, 100);
819    }
820    </script>
820
821
822        <footer class="border-t border-gray-200 bg-mist/30" role="contentinfo">
823      <div class="mx-auto flex max-w-7xl flex-col items-center gap-y-4 px-6 py-10 sm:flex-row sm:justify-between lg:px-8">
824        <p class="text-sm text-slate">&copy; 2026 Curio Chat Academy</p>
825        <nav class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-sm" aria-label="Footer">
826          <a href="/start/" class="text-slate hover:text-ink">Start Here</a>
827          <a href="/webinar/" class="text-slate hover:text-ink">Webinar</a>
828          <a href="/three-fixes/" class="text-slate hover:text-ink">Three Fixes</a>
829          <a href="/blog/" class="font-semibold text-ink">Blog</a>
830          <a href="/manifesto/" class="text-slate hover:text-ink">Manifesto</a>
831          <a href="/consulting/" class="text-slate hover:text-ink">Consulting</a>
832          <a href="/about/" class="text-slate hover:text-ink">About</a>
833          <span class="text-slate/40" aria-hidden="true">&middot;</span>
834          <a href="/terms/" class="text-slate hover:text-ink">Terms</a>
835          <a href="/privacy/" class="text-slate hover:text-ink">Privacy</a>
836          <a href="/disclaimer/" class="text-slate hover:text-ink">Disclaimer</a>
837          <a href="/cookie-policy/" class="text-slate hover:text-ink">Cookies</a>
838          <a href="/accessibility/" class="text-slate hover:text-ink">Accessibility</a>
839        </nav>
840      </div>
841    </footer>
842
843    
843<script
844      src="https://widget.webability.io/widget.min.js"
845      data-asw-position="bottom-left-x-20-y-20"
846      data-asw-lang="auto"
847      data-asw-icon-type="s-full"
848      defer
849    ></script>
849
850<!-- WebAbility accessibility widget -->
851<script src="https://widget-v2.webability.io/widget.min.js" data-asw-position="bottom-left-x-20-y-20" data-asw-lang="auto" data-asw-icon-type="m-full" defer></script>
851
852<!-- End WebAbility accessibility widget -->
853</body>
854</html>

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.