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 & 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 & 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>✓ Owned</span> 194 <span class="block text-amber-deep" data-pack-scaffold-note>Available soon — 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 — 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 → 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>✓ Owned</span> 246 <span class="block text-amber-deep" data-pack-scaffold-note>Available soon — 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 — 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 → 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 →</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 →' : 'Learn more →'; 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">© 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">·</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.