1/** 2 * Swift Auth - Frontend Bundle 3 * 4 * Handles: 5 * - Authentication state management 6 * - Cognito integration (User Pool + Identity Pool) 7 * - Direct DynamoDB access for user data (profile, bookmarks, topics) 8 * - Regwall display 9 * - Paywall enforcement 10 * - UI state updates 11 */ 12 13(function() { 14 'use strict'; 15 16 // ========================================================================= 17 // Configuration 18 // ========================================================================= 19 20 const config = window.swiftAuthConfig || {}; 21 const ajax = window.swiftAuthAjax || {}; 22 23 // ========================================================================= 24 // State 25 // ========================================================================= 26 27 const state = { 28 authLevel: 'anonymous', // resolved client-side (no longer in config) 29 sub: null, // resolved client-side (no longer in config) 30 sessionExpires: null, // resolved client-side (no longer in config) 31 cognitoUser: null, 32 initialized: false, 33 localStorageMigrated: false, // Set to true once user data is migrated to DynamoDB 34 followedTopics: [], // Followed topics from DynamoDB 35 readingHistory: [], // Reading history from DynamoDB 36 profile: null, // User profile from DynamoDB (includes trackReadingHistory preference) 37 userDataManager: null, // UserDataManager instance for direct DynamoDB access 38 userDataManagerReady: false, // Flag to track if UserDataManager is initialized 39 forgotPasswordParams: null, // Stores params from forgot password link 40 loggedOut: false, // Set to true when logout() is called, prevents init() from re-elevating 41 }; 42 43 // Content lock state for hard walls 44 let storedPremiumContent = null; 45 46 function syncLoggedInSignal() { 47 const isLoggedIn = (state.authLevel === 'full' || state.authLevel === 'lite' || state.authLevel === 'magic'); 48 const value = isLoggedIn ? 'yes' : 'no'; 49 50 window.swiftloggedIn = value; 51 document.cookie = 'swift_logged_in=' + value + '; path=/; max-age=' + (30 * 86400) + '; SameSite=Lax'; 52 gtag('set', 'user_properties', { Page_LoggedIn: value }); 53 console.log('[SwiftAuth] GA4 Page_LoggedIn set (sync):', value, '| authLevel:', state.authLevel); 54 } 55 56 // ========================================================================= 57 // Utility Functions 58 // ========================================================================= 59 60 /** 61 * Get current Cognito ID token (if available and valid) 62 * Returns null if no valid session 63 */ 64 async function getIdToken() { 65 if (!window.swiftAuthUserPool) { 66 return null; 67 } 68 69 const cognitoUser = window.swiftAuthUserPool.getCurrentUser(); 70 if (!cognitoUser) { 71 return null; 72 } 73 74 return new Promise((resolve) => { 75 cognitoUser.getSession((err, session) => { 76 if (err || !session || !session.isValid()) { 77 resolve(null); 78 return; 79 } 80 resolve(session.getIdToken().getJwtToken()); 81 }); 82 }); 83 } 84 85 /** 86 * Make AJAX request to WordPress 87 * Automatically includes JWT token in: 88 * - Authorization header (preferred) 89 * - POST data or query params (fallback for servers that strip Auth header) 90 */ 91 async function ajaxRequest(action, data = {}, method = 'POST', needsNonce = true) { 92 const headers = {}; 93 94 // Lazily resolve a nonce only for actions that verify one server-side. 95 // Read-only calls (e.g. swift_auth_status) pass needsNonce=false so a 96 // fresh/anonymous visitor doesn't boot WordPress for a nonce on every 97 // page load â the nonce is fetched on first write action instead. 98 if (needsNonce) { 99 await fetchNonce(); 100 } 101 102 // Get ID token 103 const idToken = await getIdToken(); 104 if (idToken) { 105 headers['Authorization'] = `Bearer ${idToken}`; 106 } 107 108 let url = ajax.ajaxUrl; 109 let body = null; 110 111 if (method === 'POST') { 112 // POST: use FormData 113 const formData = new FormData(); 114 formData.append('action', action); 115 formData.append('nonce', ajax.nonce); 116 117 for (const [key, value] of Object.entries(data)) { 118 formData.append(key, value); 119 } 120 121 // Add token to POST data as fallback 122 if (idToken) { 123 formData.append('id_token', idToken); 124 } 125 126 body = formData; 127 } else { 128 // GET: use query params 129 const params = new URLSearchParams({ 130 action: action, 131 nonce: ajax.nonce, 132 ...data, 133 }); 134 135 // Add token to query params as fallback 136 if (idToken) { 137 params.append('id_token', idToken); 138 } 139 140 url = `${ajax.ajaxUrl}?${params.toString()}`; 141 } 142 143 const options = { 144 method: method, 145 credentials: 'same-origin', 146 headers: headers, 147 }; 148 149 if (body) { 150 options.body = body; 151 } 152 153 const response = await fetch(url, options); 154 return response.json(); 155 } 156 157 // ========================================================================= 158 // Cache-Safe Async Fetchers 159 // ========================================================================= 160 161 /** 162 * Fetch a fresh WordPress nonce from the server. 163 * Nonces are no longer embedded in HTML (breaks CloudFront caching). 164 * Caches in sessionStorage for 6 hours. 165 */ 166 let nonceInFlight = null; 167 async function fetchNonce() { 168 const CACHE_KEY = 'swift_nonce'; 169 const CACHE_TTL = 2 * 60 * 60 * 1000; // 2 hours 170 171 // Already resolved earlier this page load â reuse it. 172 if (ajax.nonce) return; 173 174 // Warm from sessionStorage (persists across page loads, 2h TTL). 175 try { 176 const cached = sessionStorage.getItem(CACHE_KEY); 177 if (cached) { 178 const parsed = JSON.parse(cached); 179 if (parsed.ts && (Date.now() - parsed.ts < CACHE_TTL) && parsed.nonce) { 180 ajax.nonce = parsed.nonce; 181 return; 182 } 183 } 184 } catch (e) { /* ignore parse errors */ } 185 186 // Dedupe concurrent callers so we only ever fire one network request. 187 if (nonceInFlight) return nonceInFlight; 188 189 nonceInFlight = (async () => { 190 try { 191 const params = new URLSearchParams({ action: 'swift_get_nonce' }); 192 const resp = await fetch(`${ajax.ajaxUrl}?${params.toString()}`, { 193 method: 'GET', 194 credentials: 'same-origin', 195 }); 196 const data = await resp.json(); 197 if (data.success && data.data?.nonce) { 198 ajax.nonce = data.data.nonce; 199 try { 200 sessionStorage.setItem(CACHE_KEY, JSON.stringify({ nonce: data.data.nonce, ts: Date.now() })); 201 } catch (e) { /* storage full */ } 202 } 203 } catch (err) { 204 console.warn('SwiftAuth: Failed to fetch nonce', err); 205 } finally { 206 nonceInFlight = null; 207 } 208 })(); 209 return nonceInFlight; 210 } 211 212 /** 213 * Fetch wall config JSON from the static CDN file. 214 * Populates config.paywall.walls, config.regwall (walls), and config.whyRegisterHtml. 215 * Caches in sessionStorage for 5 minutes. 216 */ 217 async function fetchWallConfig() { 218 const url = config.wallsJsonUrl; 219 if (!url) { 220 console.log('SwiftAuth: No wallsJsonUrl configured, skipping wall config fetch'); 221 return; 222 } 223 224 const CACHE_KEY = 'swift_wall_config'; 225 const CACHE_TTL = 5 * 60 * 1000; // 5 minutes 226 227 let wallData = null; 228 229 try { 230 const cached = sessionStorage.getItem(CACHE_KEY); 231 if (cached) { 232 const parsed = JSON.parse(cached); 233 if (parsed.ts && (Date.now() - parsed.ts < CACHE_TTL) && parsed.data) { 234 wallData = parsed.data; 235 } 236 } 237 } catch (e) { /* ignore parse errors */ } 238 239 if (!wallData) { 240 try { 241 const resp = await fetch(url, { credentials: 'same-origin' }); 242 if (resp.ok) { 243 wallData = await resp.json(); 244 try { 245 sessionStorage.setItem(CACHE_KEY, JSON.stringify({ data: wallData, ts: Date.now() })); 246 } catch (e) { /* storage full */ } 247 } else { 248 console.warn('SwiftAuth: Wall config fetch failed:', resp.status); 249 } 250 } catch (err) { 251 console.warn('SwiftAuth: Failed to fetch wall config', err); 252 } 253 } 254 255 if (wallData) { 256 console.log('SwiftAuth: wallData fetched, paywall keys:', Object.keys(wallData.paywall || {}), 'walls count:', wallData.paywall?.walls?.length, 'config.paywall exists:', !!config.paywall); 257 // Populate paywall walls into config 258 if (wallData.paywall?.walls && config.paywall) { 259 config.paywall.walls = wallData.paywall.walls; 260 console.log('SwiftAuth: Assigned paywall walls:', config.paywall.walls.length, 'verify:', config.paywall.walls === wallData.paywall.walls); 261 } else { 262 console.warn('SwiftAuth: SKIPPED paywall walls assignment - wallData.paywall?.walls:', !!wallData.paywall?.walls, 'config.paywall:', !!config.paywall); 263 } 264 // Populate regwall walls into config (for future client-side use) 265 if (wallData.regwall?.walls && config.regwall) { 266 config.regwall.walls = wallData.regwall.walls; 267 } 268 // Populate whyRegisterHtml 269 if (wallData.whyRegisterHtml !== undefined) { 270 config.whyRegisterHtml = wallData.whyRegisterHtml; 271 } 272 // Populate subscriptionRequiredHtml 273 if (wallData.subscriptionRequiredHtml !== undefined) { 274 config.subscriptionRequiredHtml = wallData.subscriptionRequiredHtml; 275 } 276 } else { 277 console.warn('SwiftAuth: wallData is null/undefined after fetch'); 278 } 279 } 280 281 /** 282 * Get current article URL 283 */ 284 function getArticleUrl() { 285 return window.location.href.split('?')[0].split('#')[0]; 286 } 287 288 /** 289 * Get current article post ID from shortlink or swiftArticleMeta 290 * Returns null if not on an article page 291 */ 292 function getPostId() { 293 // First try swiftArticleMeta (most reliable if available) 294 if (window.swiftArticleMeta?.postId) { 295 return parseInt(window.swiftArticleMeta.postId, 10); 296 } 297 298 // Fallback: parse from shortlink element 299 // <link rel='shortlink' href='https://example.com/?p=12345' /> 300 const shortlink = document.querySelector('link[rel="shortlink"]'); 301 if (shortlink && shortlink.href) { 302 const match = shortlink.href.match(/[?&]p=(\d+)/); 303 if (match) { 304 return parseInt(match[1], 10); 305 } 306 } 307 308 return null; 309 } 310 311 /** 312 * Get article metadata from page 313 */ 314 function getArticleMeta() { 315 const defaults = { 316 url: getArticleUrl(), 317 title: document.title, 318 image: document.querySelector('meta[property="og:image"]')?.content || '', 319 }; 320 return window.swiftArticleMeta 321 ? { ...defaults, ...window.swiftArticleMeta } 322 : defaults; 323 } 324 325 // ========================================================================= 326 // Sync Cache Utilities (localStorage-based, no async) 327 // ========================================================================= 328 329 /** 330 * Decode Cognito ID token from localStorage (sync, no network) 331 * Returns { sub, email } or null if no valid token 332 */ 333 function getCachedIdTokenPayload() { 334 try { 335 const clientId = config.cognito?.clientId; 336 if (!clientId) return null; 337 338 const lastUserKey = `CognitoIdentityServiceProvider.${clientId}.LastAuthUser`; 339 const username = localStorage.getItem(lastUserKey); 340 if (!username) return null; 341 342 const tokenKey = `CognitoIdentityServiceProvider.${clientId}.${username}.idToken`; 343 const idToken = localStorage.getItem(tokenKey); 344 if (!idToken) return null; 345 346 const parts = idToken.split('.'); 347 if (parts.length !== 3) return null; 348 349 const payload = JSON.parse(atob(parts[1])); 350 351 // Check expiry 352 if (payload.exp && payload.exp * 1000 < Date.now()) { 353 return null; 354 } 355 356 return { sub: payload.sub || null, email: payload.email || null }; 357 } catch (e) { 358 return null; 359 } 360 } 361 362 /** 363 * Read cached subscription from localStorage (sync, no network) 364 */ 365 function getCachedSubscription() { 366 try { 367 const pubID = config.pubID || ''; 368 const data = localStorage.getItem(pubID + 'Subscription'); 369 return data ? JSON.parse(data) : null; 370 } catch (e) { 371 return null; 372 } 373 } 374 375 /** 376 * Pure function to evaluate entitlement from a subscription object 377 * Returns { hasActive, tier, endDate, isExpired, autoRenew } 378 */ 379 function evaluateEntitlement(entitlement) { 380 if (!entitlement) { 381 return { hasActive: false, tier: 'free', endDate: null, isExpired: false, autoRenew: false }; 382 } 383 384 const status = entitlement.status; 385 const tier = entitlement.tier || entitlement.type || 'free';
386 387 // Comp tiers (employee/complimentary) are perpetual â grant regardless of status/endDate. 388 if (['employee', 'gratis'].includes(tier)) { 389 const compEnd = entitlement.endDate || entitlement.expires_at || entitlement.expiresAt || entitlement.expires || null; 390 return { hasActive: true, tier, endDate: compEnd, isExpired: false, autoRenew: entitlement.autoRenew || entitlement.auto_renew || false }; 391 } 392 393 const hasActiveTier = (status === 'active' || ['subscriber', 'employee', 'gratis', 'trial'].includes(tier)); 394 395 const endDate = entitlement.endDate || entitlement.expires_at || entitlement.expiresAt || entitlement.expires; 396 let isExpired = false; 397 if (endDate && hasActiveTier) { 398 const expiry = new Date(typeof endDate === 'number' ? endDate * 1000 : endDate); 399 isExpired = expiry < new Date(); 400 } 401 402 const hasActive = hasActiveTier && !isExpired; 403 const autoRenew = entitlement.autoRenew || entitlement.auto_renew || false; 404 405 return { hasActive, tier, endDate, isExpired, autoRenew }; 406 } 407 408 // ========================================================================= 409 // Content Lock (for hard walls) 410 // ========================================================================= 411 412 /** 413 * Show the dark overlay behind modals 414 */ 415 function showOverlay() { 416 let overlay = document.querySelector('.swift-wall-overlay'); 417 if (!overlay) { 418 overlay = document.createElement('div'); 419 overlay.className = 'swift-wall-overlay'; 420 document.body.appendChild(overlay); 421 } 422 overlay.style.display = 'block'; 423 } 424 425 /** 426 * Hide the dark overlay 427 */ 428 function hideOverlay() { 429 const overlay = document.querySelector('.swift-wall-overlay'); 430 if (overlay) { 431 overlay.style.display = 'none'; 432 } 433 } 434 435 /** 436 * Remove content from .p402_premium elements 437 */ 438 function lockContent() { 439 const article = document.querySelector('.p402_premium'); 440 if (!article) return; 441 442 // Store original content 443 storedPremiumContent = article.innerHTML; 444 445 // Clear the content 446 article.innerHTML = ''; 447 448 // Add locked class for styling 449 article.classList.add('swift-content-locked'); 450 451 // Show overlay 452 showOverlay(); 453 } 454 455 /** 456 * Defer fn() until the user scrolls past firstAd (exits viewport from top). 457 * Falls back to fn() immediately if no ad, no IntersectionObserver support, 458 * or the ad is already above the viewport on load. 459 * 460 * @param {HTMLElement|null} firstAd 461 * @param {Function} fn 462 */ 463 function waitForScrollPastFirstAd(firstAd, fn) { 464 if (!firstAd || typeof IntersectionObserver === 'undefined') { 465 fn(); 466 return; 467 } 468 const observer = new IntersectionObserver(function(entries) { 469 entries.forEach(function(entry) { 470 if (!entry.isIntersecting && entry.boundingClientRect.top < 0) { 471 observer.disconnect(); 472 fn(); 473 } 474 }); 475 }, { threshold: 0 }); 476 observer.observe(firstAd); 477 } 478 479 /** 480 * Insert a wall element after the first .module-ad inside the article, 481 * then hide all remaining siblings. If no .module-ad exists, inserts 482 * at the start of the article and hides everything after. 483 * 484 * @param {HTMLElement} article - The .p402_premium container 485 * @param {HTMLElement} wallElement - The wall div to insert 486 * @returns {Function} restore function that unhides content (for soft/dismissable walls) 487 */ 488 function insertWallAfterFirstAd(article, wallElement, useBlur) { 489 const firstAd = article.querySelector('.module-ad'); 490 const hiddenElements = []; 491 const blurredElements = []; 492 493 if (firstAd) { 494 // Insert wall right after the first ad 495 firstAd.after(wallElement); 496 } else { 497 // No .module-ad found â fall back to inserting before .p402_content or at start 498 const contentDiv = article.querySelector('.p402_content'); 499 const insertBefore = contentDiv || article.firstChild; 500 article.insertBefore(wallElement, insertBefore); 501 } 502 503 // Process all remaining content after the wall. 504 // The wall is inserted after .module-ad, which may be nested inside wrappers 505 // (e.g. div.row > div.col-sm-12 > .module-ad). We need to: 506 // 1. Process siblings after the wall at its insertion level 507 // 2. Walk up each ancestor to the article, processing siblings after each ancestor 508 let current = wallElement; 509 while (current && current !== article) { 510 let sibling = current.nextElementSibling; 511 while (sibling) {
512 // Never blur/hide ad units 513 const isAd = sibling.classList.contains('module-ad') || sibling.querySelector('.module-ad'); 514 if (!isAd) { 515 if (useBlur) { 516 sibling.classList.add('swift-wall-blur-content'); 517 blurredElements.push(sibling); 518 } else { 519 sibling.classList.add('swift-wall-hidden'); 520 hiddenElements.push(sibling); 521 } 522 } 523 sibling = sibling.nextElementSibling; 524 } 525 current = current.parentElement; 526 } 527 528 // Return a restore function for dismissable walls 529 return function restoreContent() { 530 hiddenElements.forEach(el => el.classList.remove('swift-wall-hidden')); 531 blurredElements.forEach(el => el.classList.remove('swift-wall-blur-content')); 532 }; 533 } 534 535 /** 536 * Generate lorem ipsum HTML paragraphs approximating a target character length 537 */ 538 function generateLipsumHtml(targetLength) { 539 const sentences = [ 540 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', 541 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', 542 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.', 543 'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore.', 544 'Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia.', 545 'Nulla facilisi morbi tempus iaculis urna id volutpat lacus.', 546 'Amet consectetur adipiscing elit pellentesque habitant morbi tristique senectus.', 547 'Viverra accumsan in nisl nisi scelerisque eu ultrices vitae auctor.', 548 'Turpis egestas maecenas pharetra convallis posuere morbi leo urna.', 549 'Pellentesque elit eget gravida cum sociis natoque penatibus et magnis.', 550 'Faucibus pulvinar elementum integer enim neque volutpat ac tincidunt vitae.', 551 'Adipiscing commodo elit at imperdiet dui accumsan sit amet nulla.', 552 ]; 553 554 let html = ''; 555 let charCount = 0; 556 // Cap at a reasonable length to avoid enormous DOM 557 const maxLength = Math.min(targetLength || 2000, 5000); 558 559 while (charCount < maxLength) { 560 let para = ''; 561 // 3-6 sentences per paragraph 562 const sentenceCount = 3 + Math.floor(Math.random() * 4); 563 for (let i = 0; i < sentenceCount && charCount < maxLength; i++) { 564 const sentence = sentences[Math.floor(Math.random() * sentences.length)]; 565 para += sentence + ' '; 566 charCount += sentence.length + 1; 567 } 568 html += '<p>' + para.trim() + '</p>'; 569 } 570 571 return html; 572 } 573 574 /** 575 * Restore content 576 */ 577 function unlockContent() { 578 const article = document.querySelector('.p402_premium'); 579 580 // Restore content if we have it stored 581 if (article && storedPremiumContent !== null) { 582 article.innerHTML = storedPremiumContent; 583 storedPremiumContent = null; 584 } 585 586 // Remove locked/hidden classes 587 if (article) { 588 article.classList.remove('swift-content-locked'); 589 article.classList.remove('swift-content-blurred'); 590 article.classList.remove('swift-content-hidden'); 591 } 592 593 // Hide overlay 594 hideOverlay(); 595 } 596 597 // ========================================================================= 598 // Cognito Integration 599 // ========================================================================= 600 601 /** 602 * Initialize Cognito (requires AWS Amplify or Cognito JS SDK) 603 */ 604 function initCognito() { 605 if (!config.cognito || !config.cognito.userPoolId) { 606 console.log('SwiftAuth: Cognito not configured'); 607 return; 608 } 609 610 // Check if AWS SDK is available 611 if (typeof AWS !== 'undefined' && typeof AmazonCognitoIdentity !== 'undefined') { 612 const poolData = { 613 UserPoolId: config.cognito.userPoolId, 614 ClientId: config.cognito.clientId, 615 }; 616 617 window.swiftAuthUserPool = new AmazonCognitoIdentity.CognitoUserPool(poolData); 618 } 619 } 620 621 /** 622 * Initialize UserDataManager for direct DynamoDB access 623 * Requires valid Cognito ID token to get Identity Pool credentials 624 * 625 * @param {string}
625 idToken - JWT ID token from Cognito User Pool 626 * @returns {Promise<boolean>} - True if initialized successfully 627 */ 628 async function initUserDataManager(idToken) { 629 console.log('SwiftAuth: initUserDataManager config:', { 630 identityPoolId: config.identityPoolId ? config.identityPoolId.substring(0, 20) + '...' : 'MISSING', 631 userPoolId: config.cognito?.userPoolId, 632 region: config.dynamodb?.region || config.cognito?.region || 'us-west-2', 633 tableName: config.dynamodb?.tableName, 634 tokenLength: idToken ? idToken.length : 0 635 }); 636 637 if (!config.identityPoolId) { 638 console.error('SwiftAuth: Identity Pool ID not configured - user data operations will fail'); 639 return false; 640 } 641 642 if (typeof SwiftUserDataManager === 'undefined') { 643 console.error('SwiftAuth: UserDataManager not loaded - user data operations will fail'); 644 return false; 645 } 646 647 try { 648 state.userDataManager = new SwiftUserDataManager({ 649 pubID: config.pubID, 650 tableName: config.dynamodb?.tableName || 'SwiftUserProfiles-prod', 651 region: config.dynamodb?.region || config.cognito?.region || 'us-west-2', 652 identityPoolId: config.identityPoolId, 653 userPoolId: config.cognito?.userPoolId, 654 debug: true // Enable debug logging 655 }); 656 657 const success = await state.userDataManager.initialize(idToken); 658 659 if (success) { 660 state.userDataManagerReady = true; 661 console.log('SwiftAuth: UserDataManager initialized for direct DynamoDB access'); 662 return true; 663 } else { 664 const initErr = state.userDataManager.initError; 665 console.error('SwiftAuth: UserDataManager initialization failed - user data operations will fail'); 666 if (initErr) { 667 console.error('SwiftAuth: Root cause:', initErr.code || initErr.name, '-', initErr.message); 668 } 669 return false; 670 } 671 } catch (error) { 672 console.error('SwiftAuth: Error initializing UserDataManager:', error); 673 return false; 674 } 675 } 676 677 /** 678 * Check if UserDataManager is ready for direct DynamoDB calls 679 */ 680 function isDirectDynamoEnabled() { 681 return state.userDataManagerReady && state.userDataManager && state.userDataManager.isReady(); 682 } 683 684 /** 685 * Get diagnostic info about why DynamoDB might not be enabled 686 */ 687 function getDynamoDBDiagnostics() { 688 // Check if Cognito user exists 689 const cognitoUser = window.swiftAuthUserPool?.getCurrentUser?.(); 690 691 const diagnostics = { 692 identityPoolIdConfigured: !!config.identityPoolId, 693 awsSdkLoaded: typeof AWS !== 'undefined', 694 cognitoSdkLoaded: typeof AmazonCognitoIdentity !== 'undefined', 695 userDataManagerClassLoaded: typeof SwiftUserDataManager !== 'undefined', 696 userDataManagerCreated: !!state.userDataManager, 697 userDataManagerReady: state.userDataManagerReady, 698 isReady: state.userDataManager?.isReady?.() || false, 699 authLevel: state.authLevel, 700 cognitoUserExists: !!cognitoUser, 701 userPoolInitialized: !!window.swiftAuthUserPool, 702 }; 703 704 // Determine the likely cause 705 if (!diagnostics.identityPoolIdConfigured) { 706 diagnostics.likelyCause = 'Identity Pool ID not configured in WordPress admin settings'; 707 } else if (!diagnostics.awsSdkLoaded) { 708 diagnostics.likelyCause = 'AWS SDK failed to load'; 709 } else if (!diagnostics.userDataManagerClassLoaded) { 710 diagnostics.likelyCause = 'UserDataManager script failed to load'; 711 } else if (!diagnostics.userPoolInitialized) { 712 diagnostics.likelyCause = 'Cognito User Pool not initialized - check userPoolId and clientId settings'; 713 } else if (!diagnostics.cognitoUserExists) { 714 diagnostics.likelyCause = 'No Cognito user in localStorage - user needs to log in'; 715 } else if (!diagnostics.userDataManagerCreated) { 716 diagnostics.likelyCause = 'UserDataManager not created - initUserDataManagerFromCognitoSession may not have run'; 717 } else if (!diagnostics.userDataManagerReady) { 718 diagnostics.likelyCause = 'UserDataManager initialization failed - check console for AWS credential errors'; 719 } else { 720 diagnostics.likelyCause = 'Unknown - DynamoDB should be working'; 721 } 722 723 return diagnostics; 724 } 725 726 /** 727 * Update Pinpoint endpoint with cognito_sub after login 728 * This enables magic link token generation for email campaigns 729 * 730 * @param {string} sub - Cognito user sub 731 * @param {string} email - User's email address 732 */ 733 async function updatePinpointEndpoint(sub, email) { 734 console.log('SwiftAuth Debug: updatePinpointEndpoint called', { sub, email }); 735 736 // Check if Pinpoint integration is enabled and App ID is configured 737 const pinpointAppId = config.pinpoint?.appId; 738 console.log('SwiftAuth Debug: Pinpoint config', { 739 enabled: config.pinpoint?.enabled, 740 appId: pinpointAppId,
741 region: config.pinpoint?.region 742 }); 743 744 if (!config.pinpoint?.enabled || !pinpointAppId || typeof pinpointAppId !== 'string' || pinpointAppId.trim() === '') { 745 console.log('SwiftAuth Debug: Pinpoint not configured or App ID missing, skipping endpoint update'); 746 return; 747 } 748 749 // Check if AWS SDK is available 750 if (typeof AWS === 'undefined') { 751 console.log('SwiftAuth Debug: AWS SDK not loaded, skipping Pinpoint endpoint update'); 752 return; 753 } 754 755 try { 756 // Check if Pinpoint is available in the AWS SDK bundle 757 if (typeof AWS.Pinpoint !== 'function') { 758 console.log('SwiftAuth Debug: AWS.Pinpoint not available in SDK bundle, skipping endpoint update'); 759 return; 760 } 761 762 console.log('SwiftAuth Debug: Initializing AWS.Pinpoint client'); 763 // Use the same credentials from the Identity Pool that UserDataManager uses 764 // The credentials should already be initialized from initUserDataManager 765 const pinpoint = new AWS.Pinpoint({ 766 region: config.pinpoint.region || config.cognito?.region || 'us-west-2', 767 }); 768 769 // Use email as endpoint ID, matching the legacy/external system format: 770 // 1. Normalize email (lowercase and trim) 771 // 2. Base64 encode the normalized email 772 // 3. Lowercase the resulting Base64 string 773 const normalizedEmail = email.toLowerCase().trim(); 774 const endpointId = btoa(normalizedEmail).toLowerCase(); 775 776 console.log('SwiftAuth Debug: Generated Endpoint ID', { 777 originalEmail: email, 778 normalizedEmail: normalizedEmail, 779 endpointId: endpointId 780 }); 781 782 const params = { 783 ApplicationId: config.pinpoint.appId, 784 EndpointId: endpointId, 785 EndpointRequest: { 786 Address: email, 787 ChannelType: 'EMAIL', 788 Attributes: { 789 cognito_sub: [sub], 790 }, 791 User: { 792 UserId: sub, 793 }, 794 }, 795 }; 796 797 console.log('SwiftAuth Debug: Sending Pinpoint updateEndpoint request', params); 798 799 await pinpoint.updateEndpoint(params).promise(); 800 console.log('SwiftAuth Debug: Pinpoint endpoint updated successfully with cognito_sub'); 801 } catch (err) { 802 // Don't fail login if Pinpoint update fails - just log it 803 console.error('SwiftAuth Debug: Failed to update Pinpoint endpoint:', err); 804 console.error('SwiftAuth Debug: Error details:', { 805 message: err.message, 806 code: err.code, 807 statusCode: err.statusCode, 808 requestId: err.requestId 809 }); 810 } 811 } 812 813 /** 814 * Sync user profile attributes from Cognito JWT to DynamoDB 815 * Called after login to ensure profile data (name, email, etc.) is available 816 * 817 * @param {string} idToken - The Cognito ID token (JWT) 818 */ 819 async function syncUserProfileFromToken(idToken) { 820 if (!state.userDataManager) { 821 console.log('SwiftAuth: UserDataManager not ready, skipping profile sync'); 822 return; 823 } 824 825 try { 826 // Parse JWT token to extract user attributes 827 const tokenParts = idToken.split('.'); 828 if (tokenParts.length !== 3) { 829 console.log('SwiftAuth: Invalid token format for profile sync'); 830 return; 831 } 832 833 const payload = JSON.parse(atob(tokenParts[1])); 834 835 // Extract relevant user attributes from Cognito token 836 const updates = {}; 837 838 if (payload.email) { 839 updates.email = payload.email; 840 } 841 if (payload.given_name) { 842 updates.givenName = payload.given_name; 843 } 844 if (payload.family_name) { 845 updates.familyName = payload.family_name; 846 } 847 if (payload.name) { 848 updates.name = payload.name; 849 } 850 if (payload.picture) { 851 updates.picture = payload.picture; 852 } 853 if (payload.birthdate) { 854 updates.birthdate = payload.birthdate; 855 } 856 if (payload.gender) { 857 updates.gender = payload.gender; 858 } 859 860 // Always include identity metadata â for new users this is the first 861 // write that creates the profile item in DynamoDB. 862 if (payload.sub) { 863 updates.sub = payload.sub; 864 } 865 if (state.userDataManager.userPoolSub) { 866 updates.identityId = state.userDataManager.userPoolSub; 867 } 868 updates.entityType = 'PROFILE'; 869 870 // Only update if we have attributes to sync 871 if (Object.keys(updates).length === 0) { 872 console.log('SwiftAuth: No user attributes to sync from token'); 873 return; 874 } 875 876 console.log('SwiftAuth: Syncing user attributes to DynamoDB:', Object.keys(updates)); 877 const existing = await state.userDataManager.getProfile(); 878 if (existing) { 879 await state.userDataManager.updateProfile(updates); 880 } else { 881 await state.userDataManager.saveProfile(updates); 882 } 883 console.log('SwiftAuth: User profile synced successfully'); 884 885 } catch (err) { 886 // Don't fail login if profile sync fails 887 console.error('SwiftAuth: Failed to sync user profile:', err.message); 888 } 889 } 890 891 /** 892 * Initialize UserDataManager from existing Cognito session 893 * Called on page load to ensure DynamoDB access is available for logged-in users 894 */ 895 async function initUserDataManagerFromCognitoSession() { 896 if (!window.swiftAuthUserPool) { 897 console.log('SwiftAuth: User pool not initialized, skipping UserDataManager init'); 898 return; 899 } 900 901 const cognitoUser = window.swiftAuthUserPool.getCurrentUser(); 902 if (!cognitoUser) { 903 console.log('SwiftAuth: No Cognito user, skipping UserDataManager init'); 904 return; 905 } 906 907 return new Promise((resolve) => { 908 cognitoUser.getSession(async (err, session) => { 909 if (err || !session || !session.isValid()) { 910 console.log('SwiftAuth: No valid Cognito session for UserDataManager'); 911 resolve(); 912 return; 913 } 914 915 const idToken = session.getIdToken().getJwtToken(); 916 console.log('SwiftAuth: Initializing UserDataManager from Cognito session'); 917 918 // Initialize UserDataManager for direct DynamoDB access 919 const success = await initUserDataManager(idToken); 920 921 if (success) { 922 console.log('SwiftAuth: UserDataManager ready for DynamoDB operations'); 923 // Conditional hydration: profile page needs everything, other pages only need subscription 924 const isProfilePage = !!document.getElementById('swift-profile-root'); 925 if (isProfilePage) { 926 await state.userDataManager.hydrate(); 927 } else { 928 await state.userDataManager.hydrateSubscriptionOnly(); 929 } 930 931 // Repair missing profile attributes (GSI1 keys, email, sub, identityId). 932 // Profiles created before the UserDataService fix may be missing these. 933 try { 934 let profile = state.userDataManager.cache.profile; 935 if (!profile) { 936 profile = await state.userDataManager.getProfile(); 937 } 938 if (profile) { 939 const needsRepair = !profile.GSI1PK || !profile.email || !profile.sub || !profile.identityId || !profile.entityType; 940 if (needsRepair) { 941 console.log('SwiftAuth: Repairing profile â missing:', [ 942 !profile.GSI1PK && 'GSI1PK', 943 !profile.email && 'email', 944 !profile.sub && 'sub', 945 !profile.identityId && 'identityId', 946 !profile.entityType && 'entityType'
947 ].filter(Boolean).join(', ')); 948 // Parse email from the Cognito session JWT 949 const repairUpdates = { 950 sub: profile.sub || state.sub, 951 identityId: state.userDataManager.userPoolSub, 952 entityType: 'PROFILE' 953 }; 954 // Get email from the current session token 955 try { 956 const tokenPayload = JSON.parse(atob(idToken.split('.')[1])); 957 if (tokenPayload.email) repairUpdates.email = tokenPayload.email; 958 if (tokenPayload.sub) repairUpdates.sub = tokenPayload.sub; 959 } catch (e) { /* ignore parse errors */ } 960 await state.userDataManager.updateProfile(repairUpdates); 961 console.log('SwiftAuth: Profile repaired'); 962 } 963 } 964 } catch (repairErr) { 965 console.error('SwiftAuth: Profile repair failed (non-blocking):', repairErr.message); 966 } 967 } else { 968 const err = state.userDataManager?.initError; 969 console.error('SwiftAuth: Failed to initialize UserDataManager'); 970 if (err) { 971 console.error('SwiftAuth: AWS Error:', err.code, '-', err.message); 972 } 973 } 974 975 // If WordPress thinks user is anonymous but Cognito has session, sync them 976 if (state.authLevel === 'anonymous') { 977 console.log('SwiftAuth: Syncing Cognito session with WordPress'); 978 try { 979 const response = await ajaxRequest('swift_auth_complete', { 980 id_token: idToken, 981 }); 982 983 if (response.success) { 984 state.authLevel = 'full'; 985 state.sub = response.sub; 986 console.log('SwiftAuth: WordPress session synced with Cognito'); 987 updateUI(); 988 } 989 } catch (syncErr) { 990 console.error('SwiftAuth: Session sync error', syncErr); 991 } 992 } 993 994 resolve(); 995 }); 996 }); 997 } 998 999 /** 1000 * Check for existing Cognito session and sync with WordPress 1001 * Uses refresh token to get new ID token if needed 1002 * Also initializes UserDataManager for direct DynamoDB access 1003 * @deprecated Use initUserDataManagerFromCognitoSession instead 1004 */ 1005 async function checkCognitoSession() { 1006 if (!window.swiftAuthUserPool) { 1007 return null; 1008 } 1009 1010 const cognitoUser = window.swiftAuthUserPool.getCurrentUser(); 1011 if (!cognitoUser) { 1012 console.log('SwiftAuth: No Cognito user found in localStorage'); 1013 return null; 1014 } 1015 1016 return new Promise((resolve) => { 1017 cognitoUser.getSession(async (err, session) => { 1018 if (err) { 1019 console.log('SwiftAuth: Cognito session error', err.message); 1020 resolve(null); 1021 return; 1022 } 1023 1024 if (!session || !session.isValid()) { 1025 console.log('SwiftAuth: Cognito session invalid or expired'); 1026 resolve(null); 1027 return; 1028 } 1029 1030 console.log('SwiftAuth: Valid Cognito session found'); 1031 1032 const idToken = session.getIdToken().getJwtToken(); 1033 1034 // Initialize UserDataManager for direct DynamoDB access 1035 await initUserDataManager(idToken); 1036 1037 // Hydrate cache immediately 1038 if (state.userDataManager) { 1039 state.userDataManager.hydrate(); 1040 } 1041 1042 // If WordPress session is anonymous but Cognito has valid session, 1043 // sync them by sending the ID token to WordPress 1044 if (state.authLevel === 'anonymous') { 1045 console.log('SwiftAuth: Syncing Cognito session with WordPress'); 1046 1047 try { 1048 const response = await ajaxRequest('swift_auth_complete', { 1049 id_token: idToken, 1050 }); 1051 1052 if (response.success) { 1053 state.authLevel = 'full'; 1054 state.sub = response.sub; 1055 console.log('SwiftAuth: WordPress session synced with Cognito'); 1056 updateUI(); 1057 resolve(session); 1058 } else { 1059 console.log('SwiftAuth: Failed to sync session', response.error); 1060 resolve(null); 1061 } 1062 } catch (syncErr) { 1063 console.error('SwiftAuth: Session sync error', syncErr); 1064 resolve(null); 1065 } 1066 } else { 1067 resolve(session); 1068 } 1069 }); 1070 }); 1071 } 1072 1073 /** 1074 * Login with email/password 1075 */ 1076 async function login(email, password) { 1077 if (!window.swiftAuthUserPool) { 1078 throw new Error('Cognito not initialized'); 1079 } 1080 1081 return new Promise((resolve, reject) => {
1082 const authDetails = new AmazonCognitoIdentity.AuthenticationDetails({ 1083 Username: email, 1084 Password: password, 1085 }); 1086 1087 const userData = { 1088 Username: email, 1089 Pool: window.swiftAuthUserPool, 1090 }; 1091 1092 const cognitoUser = new AmazonCognitoIdentity.CognitoUser(userData); 1093 1094 cognitoUser.authenticateUser(authDetails, { 1095 onSuccess: async (result) => { 1096 const idToken = result.getIdToken().getJwtToken(); 1097 1098 // Initialize UserDataManager for direct DynamoDB access 1099 await initUserDataManager(idToken); 1100 1101 // Hydrate cache immediately 1102 if (state.userDataManager) { 1103 state.userDataManager.hydrate(); 1104 } 1105 1106 // Send to server to sync profile (WordPress still handles this for session) 1107 const response = await ajaxRequest('swift_auth_complete', { 1108 id_token: idToken, 1109 }); 1110 1111 if (response.success) { 1112 state.authLevel = 'full'; 1113 state.sub = response.sub; 1114 updateUI(); 1115 1116 // Track login completed 1117 if (window.SwiftAnalytics) { 1118 window.SwiftAnalytics.trackLoginCompleted('email'); 1119 } 1120 1121 // Emit login event for authBridge to trigger hydration 1122 window.dispatchEvent(new CustomEvent('swiftauth:login')); 1123 1124 // Sync user profile attributes from Cognito to DynamoDB 1125 // MUST be awaited â for new users this creates the profile, 1126 // and the page redirects immediately after login resolves. 1127 await syncUserProfileFromToken(idToken); 1128 1129 // Record login event for dashboard analytics (fire-and-forget) 1130 if (state.userDataManager) state.userDataManager.recordLogin(); 1131 1132 // Update Pinpoint endpoint for magic link support (fire-and-forget is OK) 1133 updatePinpointEndpoint(response.sub, email); 1134 1135 resolve(response); 1136 } else { 1137 // Track login failed 1138 if (window.SwiftAnalytics) { 1139 window.SwiftAnalytics.trackLoginFailed(response.error || 'server_error', 'email'); 1140 } 1141 reject(new Error(response.error || 'Login failed')); 1142 } 1143 }, 1144 onFailure: (err) => { 1145 // Track login failed 1146 if (window.SwiftAnalytics) { 1147 window.SwiftAnalytics.trackLoginFailed(err.code || err.message || 'cognito_error', 'email'); 1148 } 1149 reject(err); 1150 }, 1151 newPasswordRequired: (userAttributes, requiredAttributes) => { 1152 // Handle new password required 1153 state.cognitoUser = cognitoUser; 1154 reject({ code: 'NewPasswordRequired', userAttributes, requiredAttributes }); 1155 }, 1156 }); 1157 }); 1158 } 1159 1160 /** 1161 * Register new user 1162 */ 1163 async function register(email, password, attributes = {}) { 1164 if (!window.swiftAuthUserPool) { 1165 throw new Error('Cognito not initialized'); 1166 } 1167 1168 return new Promise((resolve, reject) => { 1169 const attributeList = []; 1170 1171 if (attributes.email) { 1172 attributeList.push(new AmazonCognitoIdentity.CognitoUserAttribute({ 1173 Name: 'email', 1174 Value: attributes.email, 1175 })); 1176 } 1177 1178 if (attributes.name) { 1179 attributeList.push(new AmazonCognitoIdentity.CognitoUserAttribute({ 1180 Name: 'name', 1181 Value: attributes.name, 1182 })); 1183 } 1184 1185 // Track signup started 1186 if (window.SwiftAnalytics) { 1187 window.SwiftAnalytics.trackSignupStarted('email'); 1188 } 1189 1190 window.swiftAuthUserPool.signUp(email, password, attributeList, null, (err, result) => { 1191 if (err) { 1192 reject(err); 1193 } else { 1194 // Track signup completed 1195 if (window.SwiftAnalytics) { 1196 window.SwiftAnalytics.trackSignupCompleted('email'); 1197 } 1198 resolve(result); 1199 } 1200 }); 1201 }); 1202 } 1203 1204 /** 1205 * Confirm registration with code 1206 */ 1207 async function confirmRegistration(email, code) { 1208 if (!window.swiftAuthUserPool) { 1209 throw new Error('Cognito not initialized'); 1210 } 1211 1212 return new Promise((resolve, reject) => { 1213 const userData = { 1214 Username: email, 1215 Pool: window.swiftAuthUserPool, 1216 }; 1217 1218 const cognitoUser = new AmazonCognitoIdentity.CognitoUser(userData); 1219 1220 cognitoUser.confirmRegistration(code, true, (err, result) => { 1221 if (err) { 1222 reject(err); 1223 } else { 1224 resolve(result); 1225 } 1226 }); 1227 }); 1228 } 1229 1230 /** 1231 * Forgot password - send code 1232 */ 1233 async function forgotPassword(email) { 1234 if (!window.swiftAuthUserPool) { 1235 throw new Error('Cognito not initialized'); 1236 } 1237 1238 // Track password reset requested 1239 if (window.SwiftAnalytics) { 1240 window.SwiftAnalytics.track('password_reset_requested', { email_provided: true }); 1241 } 1242 1243 const attemptForgotPassword = () => { 1244 return new Promise((resolve, reject) => { 1245 const userData = { 1246 Username: email, 1247 Pool: window.swiftAuthUserPool, 1248 }; 1249 const cognitoUser = new AmazonCognitoIdentity.CognitoUser(userData); 1250 cognitoUser.forgotPassword({ 1251 onSuccess: resolve, 1252 onFailure: reject, 1253 }); 1254 }); 1255 }; 1256 1257 try { 1258 return await attemptForgotPassword(); 1259 } catch (err) { 1260 // Cognito rejects forgotPassword for FORCE_CHANGE_PASSWORD users. 1261 // Recover server-side (AdminSetUserPassword to CONFIRMED), then retry. 1262 const msg = (err.message || '').toLowerCase(); 1263 if (msg.includes('password cannot be reset') || msg.includes('user password cannot be reset')) { 1264 console.log('SwiftAuth: User in FORCE_CHANGE_PASSWORD state, attempting recovery...'); 1265 try { 1266 await fetchNonce(); 1267 const resp = await fetch(ajax.ajaxUrl || '/wp-admin/admin-ajax.php', { 1268 method: 'POST', 1269 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 1270 body: new URLSearchParams({ 1271 action: 'swift_forgot_password_recover', 1272 nonce: ajax.nonce, 1273 email: email, 1274 }), 1275 }); 1276 const data = await resp.json(); 1277 if (data.success || data.data?.success) { 1278 console.log('SwiftAuth: Recovery successful, retrying forgotPassword...'); 1279 return await attemptForgotPassword(); 1280 } 1281 } catch (recoveryErr) { 1282 console.error('SwiftAuth: FORCE_CHANGE_PASSWORD recovery failed', recoveryErr); 1283 } 1284 } 1285 throw err; 1286 } 1287 } 1288 1289 /** 1290 * Confirm new password 1291 */ 1292 async function confirmPassword(email, code, newPassword) { 1293 if (!window.swiftAuthUserPool) { 1294 throw new Error('Cognito not initialized'); 1295 } 1296 1297 return new Promise((resolve, reject) => { 1298 const userData = { 1299 Username: email, 1300 Pool: window.swiftAuthUserPool, 1301 }; 1302 1303 const cognitoUser = new AmazonCognitoIdentity.CognitoUser(userData); 1304 1305 cognitoUser.confirmPassword(code, newPassword, { 1306 onSuccess: resolve, 1307 onFailure: reject, 1308 }); 1309 }); 1310 } 1311 1312 /** 1313 * Change password for the currently authenticated user 1314 * @param {string} oldPassword - Current password 1315 * @param {string} newPassword - New password 1316 * @returns {Promise<string>} - 'SUCCESS' on success 1317 */ 1318 async function changePassword(oldPassword, newPassword) { 1319 return new Promise((resolve, reject) => { 1320 const cognitoUser = window.swiftAuthUserPool.getCurrentUser(); 1321 if (!cognitoUser) { 1322 reject(new Error('No authenticated user')); 1323 return; 1324 } 1325 cognitoUser.getSession((err, session) => { 1326 if (err || !session?.isValid()) { 1327 reject(new Error('Session expired. Please sign in again.')); 1328 return; 1329 } 1330 cognitoUser.changePassword(oldPassword, newPassword, (err, result) => { 1331 if (err) reject(err); 1332 else resolve(result); 1333 }); 1334 }); 1335 }); 1336 } 1337 1338 /** 1339 * Reset password and automatically log in 1340 * @param {string} email - User's email 1341 * @param {string} code - Reset code from email 1342 * @param {string} newPassword - New password 1343 * @returns {Promise<object>} - Login response 1344 */ 1345 async function resetPasswordAndLogin(email, code, newPassword) { 1346 // First, reset the password via Cognito 1347 await confirmPassword(email, code, newPassword); 1348 1349 console.log('SwiftAuth: Password reset successful, logging in...'); 1350 1351 // Now log in with the new password 1352 const loginResponse = await login(email, newPassword); 1353 1354 return loginResponse; 1355 } 1356 1357 /** 1358 * Handle forgot password link from email 1359 * Pre-fills form fields and prepares for auto-login after reset 1360 * @param {Element} authPage - The authentication page element 1361 * @param {object} params - The forgot password params from URL 1362 */ 1363 function handleForgotPasswordLink(authPage, params) { 1364 console.log('SwiftAuth: Forgot password link detected', params); 1365 1366 // Store params for use after password reset 1367 state.forgotPasswordParams = params; 1368 1369 // Pre-fill code field 1370 const codeInput = authPage.querySelector('[name="reset_code"], [name="forgot_password_code"]'); 1371 if (codeInput && params.code) { 1372 codeInput.value = params.code; 1373 } 1374 1375 // Pre-fill email field 1376 const emailInput = authPage.querySelector('[name="reset_email"], [name="email"]'); 1377 if (emailInput && params.email) { 1378 emailInput.value = params.email; 1379 // Optionally make read-only since it came from the link 1380 emailInput.setAttribute('readonly', 'readonly'); 1381 } 1382 1383 // Dispatch event so theme can switch to password reset view 1384 document.dispatchEvent(new CustomEvent('swiftauth:forgot_password_link', { 1385 detail: params 1386 })); 1387 } 1388 1389 /** 1390 * Logout 1391 */ 1392 async function logout() { 1393 console.log('SwiftAuth: logout() starting...'); 1394 1395 // Track logout before clearing state 1396 if (window.SwiftAnalytics) { 1397 window.SwiftAnalytics.trackLogout(); 1398 } 1399 1400 // Mark as logged out so init() won't re-elevate auth level 1401 state.loggedOut = true; 1402 1403 // Reset all in-memory state 1404 state.authLevel = 'anonymous'; 1405 state.sub = null; 1406 state.profile = null; 1407 state.followedTopics = []; 1408 state.readingHistory = []; 1409 state.userDataManager = null; 1410 state.userDataManagerReady = false;
1411 1412 // Clear all plugin localStorage data (except regwall meter) 1413 const pubID = config.pubID || ''; 1414 if (pubID) { 1415 const keysToRemove = [ 1416 pubID + 'Subscription', 1417 pubID + 'bookmarkedPosts', 1418 pubID + 'readingHistory', 1419 pubID + 'recentlyViewed', 1420 pubID + 'followedTopics', 1421 pubID + 'userProfile', 1422 pubID + 'pageViews', 1423 pubID + 'Profile', 1424 pubID + 'readingHistorySetting', 1425 ]; 1426 keysToRemove.forEach(key => { 1427 try { localStorage.removeItem(key); } catch (e) { /* ignore */ } 1428 }); 1429 console.log('SwiftAuth: localStorage cleared (' + keysToRemove.length + ' keys)'); 1430 } 1431 1432 // Clear magic session token (lite-login) 1433 try { localStorage.removeItem(MAGIC_TOKEN_KEY); } catch (e) { /* ignore */ } 1434 1435 // Clear cached entitlement from sessionStorage 1436 try { sessionStorage.removeItem('swift_entitlement_cache'); } catch (e) { /* ignore */ } 1437 1438 // Update UI immediately 1439 updateUI(); 1440 1441 // Emit logout event for authBridge 1442 window.dispatchEvent(new CustomEvent('swiftauth:logout')); 1443 1444 // Clear Cognito session â signOut() clears tokens but may leave LastAuthUser 1445 if (window.swiftAuthUserPool) { 1446 const cognitoUser = window.swiftAuthUserPool.getCurrentUser(); 1447 if (cognitoUser) { 1448 cognitoUser.signOut(); 1449 console.log('SwiftAuth: Cognito signOut() called'); 1450 } 1451 } 1452 1453 // Belt-and-suspenders: remove all Cognito localStorage keys directly 1454 // This MUST run even if swiftAuthUserPool isn't initialized yet 1455 // (e.g. logout called before init on the /logout/ page) 1456 const clientId = config.cognito?.clientId; 1457 if (clientId) { 1458 const prefix = 'CognitoIdentityServiceProvider.' + clientId; 1459 const toRemove = []; 1460 for (let i = 0; i < localStorage.length; i++) { 1461 const key = localStorage.key(i); 1462 if (key && key.startsWith(prefix)) { 1463 toRemove.push(key); 1464 } 1465 } 1466 toRemove.forEach(key => { 1467 try { localStorage.removeItem(key); } catch (e) { /* ignore */ } 1468 }); 1469 console.log('SwiftAuth: Cognito localStorage keys removed (' + toRemove.length + ')'); 1470 } 1471 1472 // Clear AWS credentials cache 1473 if (typeof AWS !== 'undefined' && AWS.config && AWS.config.credentials) { 1474 AWS.config.credentials.clearCachedId(); 1475 console.log('SwiftAuth: AWS credentials cache cleared'); 1476 } 1477 1478 // Send logout request to server (clears Cognito session server-side) 1479 const response = await ajaxRequest('swift_auth_logout'); 1480 console.log('SwiftAuth: Server logout response:', response); 1481 1482 return response; 1483 } 1484 1485 /** 1486 * Delete user account permanently 1487 * Requires double confirmation from user 1488 * Deletes DynamoDB data and Cognito user directly from frontend 1489 */ 1490 /** 1491 * Read the Cognito ID token synchronously from localStorage. 1492 */ 1493 function getIdTokenSync() { 1494 try { 1495 const clientId = config.cognito?.clientId; 1496 if (!clientId) return null; 1497 const lastUserKey = `CognitoIdentityServiceProvider.${clientId}.LastAuthUser`; 1498 const username = localStorage.getItem(lastUserKey); 1499 if (!username) return null; 1500 const tokenKey = `CognitoIdentityServiceProvider.${clientId}.${username}.idToken`; 1501 return localStorage.getItem(tokenKey); 1502 } catch (e) { 1503 return null; 1504 } 1505 } 1506 1507 /** 1508 * Get the current user's email from their Cognito ID token. 1509 */ 1510 function getTokenEmail() { 1511 try { 1512 const idToken = getIdTokenSync(); 1513 if (!idToken) return null; 1514 const payload = JSON.parse(atob(idToken.split('.')[1])); 1515 return payload.email || null; 1516 } catch (e) { 1517 return null; 1518 } 1519 } 1520 1521 async function deleteAccount() { 1522 // Show confirmation modal 1523 const confirmed = await new Promise((resolve) => { 1524 // Remove any existing modal 1525 const existing = document.querySelector('.swift-delete-modal-overlay'); 1526 if (existing) existing.remove(); 1527
1528 const overlay = document.createElement('div'); 1529 overlay.className = 'swift-delete-modal-overlay'; 1530 overlay.innerHTML = ` 1531 <div class="swift-delete-modal"> 1532 <h3>Delete your account?</h3> 1533 <p>This action is <strong>permanent</strong> and cannot be undone. All your data including bookmarks, reading history, and profile information will be deleted.</p> 1534 <div class="swift-delete-modal-actions"> 1535 <button type="button" class="swift-btn swift-btn-outline swift-delete-cancel">Cancel</button> 1536 <button type="button" class="swift-btn swift-btn-danger swift-delete-confirm">Delete My Account</button> 1537 </div> 1538 </div> 1539 `; 1540 1541 overlay.querySelector('.swift-delete-cancel').addEventListener('click', () => { 1542 overlay.remove(); 1543 resolve(false); 1544 }); 1545 overlay.querySelector('.swift-delete-confirm').addEventListener('click', () => { 1546 overlay.remove(); 1547 resolve(true); 1548 }); 1549 overlay.addEventListener('click', (e) => { 1550 if (e.target === overlay) { 1551 overlay.remove(); 1552 resolve(false); 1553 } 1554 }); 1555 1556 document.body.appendChild(overlay); 1557 }); 1558 1559 if (!confirmed) { 1560 return { success: false, reason: 'cancelled' }; 1561 } 1562 1563 try { 1564 // Server-side deletion: DynamoDB data, User Pool user, Identity Pool identity 1565 console.log('SwiftAuth: Requesting server-side account deletion...'); 1566 const response = await ajaxRequest('swift_delete_account'); 1567 1568 if (!response.success) { 1569 throw new Error(response.error || 'Account deletion failed on the server.'); 1570 } 1571 1572 console.log('SwiftAuth: Server deletion results:', response.results); 1573 1574 // Clear local state 1575 state.authLevel = 'anonymous'; 1576 state.sub = null; 1577 state.profile = null; 1578 state.followedTopics = []; 1579 state.readingHistory = []; 1580 state.userDataManager = null; 1581 state.userDataManagerReady = false; 1582 1583 // Sign out locally to clear cached Cognito tokens from localStorage 1584 if (window.swiftAuthUserPool) { 1585 const cognitoUser = window.swiftAuthUserPool.getCurrentUser(); 1586 if (cognitoUser) { 1587 cognitoUser.signOut(); 1588 } 1589 } 1590 1591 // Clear any localStorage data 1592 const pubID = config.pubID || ''; 1593 const keysToRemove = [ 1594 pubID + 'bookmarkedPosts', 1595 pubID + 'readingHistory', 1596 pubID + 'followedTopics', 1597 pubID + 'userProfile', 1598 pubID + 'pageViews', 1599 pubID + 'Profile', 1600 pubID + 'Subscription' 1601 ]; 1602 keysToRemove.forEach(key => { 1603 try { 1604 localStorage.removeItem(key); 1605 } catch (e) { 1606 // Ignore localStorage errors 1607 } 1608 }); 1609 1610 // Show success message and redirect 1611 alert('Your account has been deleted successfully.'); 1612 window.location.href = '/'; 1613 1614 return { success: true }; 1615 1616 } catch (error) { 1617 console.error('SwiftAuth: Account deletion failed', error); 1618 alert('Failed to delete account: ' + error.message); 1619 return { success: false, error: error.message }; 1620 } 1621 } 1622 1623 // ========================================================================= 1624 // Regwall 1625 // ========================================================================= 1626 1627 /** 1628 * Get/update regwall meter from localStorage. 1629 * Tracks unique article views per calendar month. 1630 */ 1631 function getRegwallMeter() { 1632 const STORAGE_KEY = 'swift_regwall_meter'; 1633 const rawPostId = getPostId(); 1634 const postId = (typeof rawPostId === 'number' && !isNaN(rawPostId)) ? rawPostId : null; 1635 1636 let meter; 1637 try { 1638 meter = JSON.parse(localStorage.getItem(STORAGE_KEY)); 1639 } catch (e) { 1640 meter = null; 1641 } 1642 1643 // Calendar-month reset: compare YYYY-MM of meter start vs now 1644 const nowMonth = new Date().toISOString().slice(0, 7); 1645 const meterMonth = meter && meter.started ? new Date(meter.started).toISOString().slice(0, 7) : null; 1646 if (!meter || !meter.started || !Array.isArray(meter.posts) || nowMonth !== meterMonth) { 1647 const firstOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime(); 1648 meter = { count: 0, posts: [], started: firstOfMonth }; 1649 } 1650 1651 // Increment for new unique article 1652 if (postId !== null) { 1653 const alreadyViewed = meter.posts.some(id => Number(id) === postId); 1654 if (!alreadyViewed) { 1655 meter.count++; 1656 meter.posts.push(postId); 1657 if (meter.posts.length > 200) { 1658 meter.posts = meter.posts.slice(-200); 1659 } 1660 } 1661 } 1662 1663 localStorage.setItem(STORAGE_KEY, JSON.stringify(meter)); 1664 return meter.count; 1665 } 1666 1667 /** 1668 * Determine which regwall wall to show based on count (client-side). 1669 * Mirrors determinePaywallWall() logic but uses config.regwall.walls. 1670 */ 1671 function determineRegwallWall(count) { 1672 const walls = config.regwall?.walls || []; 1673 if (!walls.length) return null; 1674 1675 const enabled = walls 1676 .map((w, i) => ({ ...w, originalIndex: i })) 1677 .filter(w => w.enabled !== false) 1678 .sort((a, b) => (b.threshold || 0) - (a.threshold || 0)); 1679 1680 for (const wall of enabled) { 1681 if (count >= (wall.threshold || 0)) { 1682 return wall; 1683 } 1684 } 1685 return null; 1686 } 1687 1688 /** 1689 * Check and handle regwall (fully client-side). 1690 * Uses localStorage meter + wall config fetched from static JSON. 1691 */ 1692 function isWallBypassed() { 1693 const meta = window.swiftArticleMeta; 1694 if (!meta) return false;
1695 1696 if (meta.freeContent === true) return true; 1697 1698 const bypassTags = config.wallBypassTags; 1699 if (bypassTags && bypassTags.length && meta.tags && meta.tags.length) { 1700 if (meta.tags.some(tag => bypassTags.includes(tag))) return true; 1701 } 1702 1703 const bypassCategories = config.wallBypassCategories; 1704 if (bypassCategories && bypassCategories.length && meta.categories && meta.categories.length) { 1705 if (meta.categories.some(cat => bypassCategories.includes(cat))) return true; 1706 } 1707 1708 return false; 1709 } 1710 1711 function checkRegwall() { 1712 if (!config.regwall || !config.regwall.enabled) { 1713 return; 1714 } 1715 1716 if (isWallBypassed()) { 1717 console.log('SwiftAuth: Regwall - bypassed (tag bypass)'); 1718 return; 1719 } 1720 1721 if (state.authLevel !== 'anonymous') { 1722 return; // Authenticated users bypass regwall 1723 } 1724 1725 const count = getRegwallMeter(); 1726 const wall = determineRegwallWall(count); 1727 1728 if (!wall) { 1729 console.log('SwiftAuth: Regwall - no wall triggered, count:', count); 1730 return; 1731 } 1732 1733 console.log('SwiftAuth: Regwall - wall triggered, type:', wall.type, 'threshold:', wall.threshold, 'count:', count); 1734 const html = wall.html || null; 1735 const wallType = wall.type || 'hard'; 1736 const shouldBlur = wallType === 'hard'; 1737 const wallLevel = (wall.originalIndex || 0) + 1; 1738 const creativeSlot = 'regwall_' + wallType + '_' + wallLevel; 1739 1740 showRegwall(html, wallType, shouldBlur, creativeSlot); 1741 1742 // Track regwall shown 1743 if (window.SwiftAnalytics) { 1744 window.SwiftAnalytics.trackRegwallShown( 1745 wallLevel, 1746 wallType, 1747 wall.threshold || 0, 1748 count 1749 ); 1750 window.SwiftAnalytics.trackWallSelectPromotion(wallType, 'Shown', creativeSlot, !!config.paywall?.enabled); 1751 } 1752 } 1753 1754 /** 1755 * Display regwall modal or inline message 1756 */ 1757 function showRegwall(html, wallType, shouldBlur, creativeSlot) { 1758 const displayMode = config.regwall?.displayMode || 'modal'; 1759 1760 // Store current article URL so user returns here after login 1761 const article = document.querySelector('.p402_premium'); 1762 if (article) { 1763 setReturnUrl(window.location.href); 1764 } 1765 1766 // Fallback HTML if none configured 1767 const fallbackHtml = wallType === 'hard' 1768 ? `<div class="swift-regwall-message"> 1769 <h2>Create a free account to continue</h2> 1770 <p>Sign up for free to access this content and more.</p> 1771 <a href="/authentication/" class="swift-btn-primary" data-swift-login>Sign Up Free</a> 1772 <p style="margin-top: 12px; font-size: 14px;">Already have an account? <a href="/authentication/" data-swift-login>Sign In</a></p> 1773 </div>` 1774 : `<div class="swift-regwall-message"> 1775 <button data-swift-close type="button">×</button> 1776 <h2>Want to keep reading?</h2> 1777 <p>Create a free account to access unlimited articles.</p> 1778 <a href="/authentication/" class="swift-btn-primary" data-swift-login>Sign Up Free</a> 1779 </div>`; 1780 1781 const content = html || fallbackHtml; 1782 1783 if (displayMode === 'inline') { 1784 // Inline mode: insert wall after first ad, hide remaining content 1785 const article = document.querySelector('.p402_premium'); 1786 if (article) { 1787 // Check if inline regwall already exists 1788 let inlineWall = article.querySelector('.swift-regwall-inline'); 1789 if (!inlineWall) { 1790 inlineWall = document.createElement('div'); 1791 inlineWall.className = 'swift-regwall-inline'; 1792 inlineWall.setAttribute('data-wall-type', wallType); 1793 inlineWall.setAttribute('data-wall-name', creativeSlot || ''); 1794 } 1795 inlineWall.innerHTML = content; 1796 1797 // Insert after first .module-ad and hide remaining content 1798 const restoreContent = insertWallAfterFirstAd(article, inlineWall, !!config.regwall?.inlineBlur); 1799 1800 // Add close handler for soft walls 1801 if (wallType !== 'hard') { 1802 const closeBtn = inlineWall.querySelector('[data-swift-close], [data-dismiss="modal"], .close'); 1803 if (closeBtn) { 1804 closeBtn.addEventListener('click', (e) => { 1805 e.preventDefault(); 1806 console.log('SwiftAuth: Regwall dismissed via close button'); 1807 if (window.SwiftAnalytics) { 1808 window.SwiftAnalytics.trackRegwallDismissed(null, wallType); 1809 window.SwiftAnalytics.trackWallSelectPromotion(wallType, 'ClickClose', creativeSlot, !!config.paywall?.enabled); 1810 } 1811 restoreContent(); 1812 inlineWall.remove(); 1813 }); 1814 } 1815 } 1816 } 1817 } else if (displayMode === 'bottom-sheet') { 1818 // Bottom-sheet mode: fixed sheet slides up from bottom, content stays visible 1819 const bsArticle = document.querySelector('.p402_premium'); 1820 let restoreContent = function() {}; 1821 1822 let sheet = document.querySelector('.swift-regwall-bottom-sheet'); 1823 if (!sheet) { 1824 sheet = document.createElement('div'); 1825 sheet.className = 'swift-bottom-sheet swift-regwall-bottom-sheet'; 1826 document.body.appendChild(sheet); 1827 } 1828 sheet.setAttribute('data-wall-type', wallType); 1829 sheet.setAttribute('data-wall-name', creativeSlot || ''); 1830 sheet.innerHTML = `<div class="swift-bottom-sheet-handle"></div><div class="swift-bottom-sheet-content">${splitBottomSheetContent(content)}</div>`; 1831 1832 if (wallType !== 'hard') { 1833 const closeBtn = sheet.querySelector('[data-swift-close], [data-dismiss="modal"], .close'); 1834 if (closeBtn) { 1835 closeBtn.addEventListener('click', (e) => { 1836 e.preventDefault(); 1837 console.log('SwiftAuth: Regwall bottom sheet dismissed'); 1838 if (window.SwiftAnalytics) { 1839 window.SwiftAnalytics.trackRegwallDismissed(null, wallType); 1840 window.SwiftAnalytics.trackWallSelectPromotion(wallType, 'ClickClose', creativeSlot, !!config.paywall?.enabled); 1841 } 1842 restoreContent(); 1843 hideBottomSheet(sheet); 1844 }); 1845 } 1846 } 1847 1848 const bsFirstAd = bsArticle ? bsArticle.querySelector('.module-ad') : null; 1849 waitForScrollPastFirstAd(bsFirstAd, function() { 1850 if (bsArticle && shouldBlur) { 1851 // Blur content below first ad using an invisible sentinel â no inline wall inserted 1852 const sentinel = document.createElement('div'); 1853 sentinel.className = 'swift-bottom-sheet-sentinel'; 1854 sentinel.style.cssText = 'height:0;overflow:hidden;margin:0;padding:0;'; 1855 restoreContent = insertWallAfterFirstAd(bsArticle, sentinel, true); 1856 } 1857 sheet.classList.add('swift-bottom-sheet-active'); 1858 }); 1859 } else { 1860 // Modal mode: show overlay 1861 const container = document.getElementById('swift-regwall-container'); 1862 if (!container) return; 1863 1864 container.innerHTML = `<div class="swift-regwall-modal">${content}</div>`; 1865 container.setAttribute('data-wall-type', wallType); 1866 container.setAttribute('data-wall-name', creativeSlot || ''); 1867 1868 if (wallType !== 'hard') { 1869 // Soft walls: attach close handler now (container is in DOM, just hidden) 1870 const closeBtn = container.querySelector('[data-swift-close], [data-dismiss="modal"], .close'); 1871 if (closeBtn) { 1872 closeBtn.addEventListener('click', (e) => { 1873 e.preventDefault(); 1874 console.log('SwiftAuth: Regwall dismissed via close button'); 1875 if (window.SwiftAnalytics) { 1876 window.SwiftAnalytics.trackRegwallDismissed(null, wallType); 1877 window.SwiftAnalytics.trackWallSelectPromotion(wallType, 'ClickClose', creativeSlot, !!config.paywall?.enabled); 1878 } 1879 hideRegwall(); 1880 }); 1881 } 1882 1883 // Allow clicking overlay to dismiss soft walls
1884 const overlay = document.querySelector('.swift-wall-overlay'); 1885 if (overlay) { 1886 overlay.addEventListener('click', (e) => { 1887 if (e.target === overlay) { 1888 console.log('SwiftAuth: Regwall dismissed via overlay click'); 1889 hideRegwall(); 1890 } 1891 }); 1892 } 1893 } 1894 1895 // Defer visibility until user scrolls past the first ad 1896 const regwallArticle = document.querySelector('.p402_premium'); 1897 const firstAd = regwallArticle ? regwallArticle.querySelector('.module-ad') : null; 1898 waitForScrollPastFirstAd(firstAd, function() { 1899 container.style.display = 'block'; 1900 requestAnimationFrame(function() { container.classList.add('swift-wall-active'); }); 1901 if (wallType === 'hard') { 1902 if (regwallArticle) regwallArticle.classList.add('swift-content-hidden'); 1903 showOverlay(); 1904 } 1905 }); 1906 } 1907 1908 // Initialize login buttons in the newly added HTML 1909 initLoginButtons(); 1910 1911 // Hide login prompts for already-authenticated users 1912 if (state.authLevel === 'full') { 1913 document.querySelectorAll('[data-swift-wall-login]').forEach(el => el.remove()); 1914 } 1915 } 1916 1917 /** 1918 * Hide regwall 1919 */ 1920 function hideRegwall() { 1921 const container = document.getElementById('swift-regwall-container'); 1922 if (container) { 1923 container.classList.remove('swift-wall-active'); 1924 container.style.display = 'none'; 1925 } 1926 unlockContent(); 1927 } 1928 1929 function hideBottomSheet(sheet) { 1930 const el = sheet || document.querySelector('.swift-bottom-sheet'); 1931 if (!el) return; 1932 el.classList.remove('swift-bottom-sheet-active'); 1933 setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 400); 1934 } 1935 1936 /** 1937 * Split wall HTML into text pane + actions pane for the two-column bottom sheet layout. 1938 * Unwraps a single wrapper div if needed, then separates headings/paragraphs (left) 1939 * from buttons, links, and other elements (right). 1940 */ 1941 function splitBottomSheetContent(html) { 1942 const temp = document.createElement('div'); 1943 temp.innerHTML = html; 1944 1945 // Unwrap a single container div (common wall HTML pattern) 1946 let root = temp; 1947 if (temp.children.length === 1 && temp.firstElementChild.tagName === 'DIV') { 1948 root = temp.firstElementChild; 1949 } 1950 1951 const TEXT_TAGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P']); 1952 const textNodes = []; 1953 const actionNodes = []; 1954 1955 Array.from(root.children).forEach(function(el) { 1956 if (TEXT_TAGS.has(el.tagName)) { 1957 // Strip text-center class and inline text-align from text elements 1958 el.classList.remove('text-center', 'text-left', 'text-right'); 1959 el.style.removeProperty('text-align'); 1960 textNodes.push(el.outerHTML); 1961 } else { 1962 actionNodes.push(el.outerHTML); 1963 } 1964 }); 1965 1966 if (textNodes.length && actionNodes.length) { 1967 return '<div class="swift-bs-text">' + textNodes.join('') + '</div>' 1968 + '<div class="swift-bs-actions">' + actionNodes.join('') + '</div>'; 1969 } 1970 1971 return html; // can't split cleanly â fall back to original 1972 } 1973 1974 // ========================================================================= 1975 // Paywall 1976 // ========================================================================= 1977 1978 /** 1979 * Check if current IP is in the paywall bypass allowlist (server-side check). 1980 * Caches result in sessionStorage for 5 minutes to avoid repeat AJAX calls. 1981 * 1982 * @returns {Promise<boolean>} true if IP bypass is granted 1983 */ 1984 async function checkIpBypass() { 1985 if (!config.paywall?.ipAllowlistEnabled) return false; 1986 1987 const CACHE_KEY = 'swift_ip_bypass'; 1988 const CACHE_TTL = 5 * 60 * 1000; // 5 minutes 1989 1990 try { 1991 const cached = sessionStorage.getItem(CACHE_KEY); 1992 if (cached) { 1993 const parsed = JSON.parse(cached); 1994 if (parsed.ts && (Date.now() - parsed.ts < CACHE_TTL)) { 1995 return parsed.bypass === true; 1996 } 1997 } 1998 } catch (e) { /* ignore parse errors */ } 1999 2000 try { 2001 // No nonce: swift_ip_bypass_check is a read-only IP check (server 2002 // verifies no nonce). Skipping it avoids a swift_get_nonce round-trip 2003 // on anonymous paywall loads. 2004 const formData = new FormData(); 2005 formData.append('action', 'swift_ip_bypass_check'); 2006 2007 const resp = await fetch(ajax.ajaxUrl, { method: 'POST', body: formData }); 2008 const data = await resp.json(); 2009 const bypass = data.bypass === true; 2010 2011 try { 2012 sessionStorage.setItem(CACHE_KEY, JSON.stringify({ bypass, ts: Date.now() })); 2013 } catch (e) { /* storage full, ignore */ } 2014 2015 return bypass; 2016 } catch (err) { 2017 console.warn('SwiftAuth: IP bypass check failed:', err); 2018 return false;
2019 } 2020 } 2021 2022 function resolveSubscribeNavBtn(isSubscriber) { 2023 const btn = document.querySelector('.js-subscribe-nav-btn'); 2024 if (!btn) return; 2025 btn.classList.toggle('d-none', isSubscriber); 2026 } 2027 2028 /** 2029 * Check and handle paywall (client-side) 2030 * Uses cached subscription for instant decision, then verifies from DynamoDB 2031 */ 2032 async function checkPaywall() { 2033 if (!config.paywall || !config.paywall.enabled) { 2034 console.log('SwiftAuth: Paywall not enabled'); 2035 return; 2036 } 2037 2038 // Tag bypass â checked before any paywall logic 2039 if (isWallBypassed()) { 2040 console.log('SwiftAuth: Paywall - bypassed (tag bypass)'); 2041 return; 2042 } 2043 2044 // IP allowlist bypass â checked before any paywall logic 2045 if (await checkIpBypass()) { 2046 console.log('SwiftAuth: Paywall - granted (IP bypass)'); 2047 return; 2048 } 2049 2050 console.log('SwiftAuth: Checking paywall, auth level:', state.authLevel); 2051 2052 // Anonymous users - check walls client-side (localStorage meter) 2053 if (state.authLevel === 'anonymous') { 2054 console.log('SwiftAuth: Paywall - checking walls for anonymous user (client-side)'); 2055 checkPaywallAnonymous(); 2056 return; 2057 } 2058 2059 // Lite-login (magic link) users go straight to server-side entitlement check. 2060 // They have no Cognito session in the browser, so skip client-side DynamoDB path. 2061 if (state.authLevel === 'lite') { 2062 console.log('SwiftAuth: Paywall - lite-login, checking server entitlement'); 2063 await checkPaywallWalls(); 2064 return; 2065 } 2066 2067 // Optimistic check: use cached subscription from localStorage 2068 const cachedSub = getCachedSubscription(); 2069 if (cachedSub) { 2070 const evalResult = evaluateEntitlement(cachedSub); 2071 if (evalResult.hasActive) { 2072 console.log('SwiftAuth: Paywall - granted (cached entitlement)'); 2073 updateEntitlementBanner({ 2074 status: cachedSub.status || 'unknown', 2075 expires_at: evalResult.endDate, 2076 plan: evalResult.tier, 2077 is_active: true, 2078 }); 2079 // Verify in background â will show paywall if cache was stale 2080 verifyPaywallInBackground(); 2081 return; 2082 } 2083 } 2084 2085 // No cached entitlement (or not active) â fall through to DynamoDB check 2086 try { 2087 let hasEntitlement = false; 2088 let entitlementData = null; 2089 2090 if (state.userDataManager) { 2091 entitlementData = await state.userDataManager.getSubscriptionOnly(); 2092 const evalResult = evaluateEntitlement(entitlementData); 2093 hasEntitlement = evalResult.hasActive; 2094 resolveSubscribeNavBtn(hasEntitlement); 2095 2096 if (entitlementData) { 2097 updateEntitlementBanner({ 2098 status: entitlementData.status || 'unknown', 2099 expires_at: evalResult.endDate, 2100 plan: evalResult.tier, 2101 is_active: hasEntitlement, 2102 }); 2103 } 2104 } 2105 2106 if (hasEntitlement) { 2107 console.log('SwiftAuth: Paywall - granted (has entitlement)'); 2108 return; 2109 } 2110 2111 // No entitlement - check server-side walls for metered access 2112 console.log('SwiftAuth: Paywall - no entitlement, checking server walls'); 2113 await checkPaywallWalls(); 2114 2115 } catch (err) { 2116 console.error('SwiftAuth: Paywall check error:', err); 2117 // Fail-open: grant access on error 2118 console.log('SwiftAuth: Paywall - granted (error fallback)'); 2119 resolveSubscribeNavBtn(false); 2120 } 2121 } 2122 2123 /** 2124 * Background verification of paywall entitlement after optimistic cache grant. 2125 * If DynamoDB says no active entitlement, shows the paywall. 2126 */
2127 function verifyPaywallInBackground() { 2128 // Wait for UserDataManager to be ready, then verify 2129 const check = async () => { 2130 if (!state.userDataManager) return; 2131 try { 2132 const entitlement = await state.userDataManager.getSubscriptionOnly(true); // force refresh 2133 const evalResult = evaluateEntitlement(entitlement); 2134 resolveSubscribeNavBtn(evalResult.hasActive); 2135 if (!evalResult.hasActive) { 2136 console.log('SwiftAuth: Paywall - cached entitlement was stale, checking server walls'); 2137 await checkPaywallWalls(); 2138 } 2139 } catch (err) { 2140 // Fail-open on background check errors (user already has access from cache) 2141 console.warn('SwiftAuth: Background paywall verification failed:', err); 2142 } 2143 }; 2144 2145 // If UserDataManager is already ready, check immediately 2146 if (state.userDataManagerReady) { 2147 check(); 2148 } else { 2149 // Otherwise wait for it via a polling interval 2150 const interval = setInterval(() => { 2151 if (state.userDataManagerReady) { 2152 clearInterval(interval); 2153 check(); 2154 } 2155 }, 500); 2156 // Give up after 15 seconds 2157 setTimeout(() => clearInterval(interval), 15000); 2158 } 2159 } 2160 2161 /** 2162 * Get/update anonymous paywall meter from localStorage 2163 * Tracks unique article views per period, resets after periodDays 2164 */ 2165 function getAnonymousPaywallMeter() { 2166 const STORAGE_KEY = 'swift_paywall_meter'; 2167 const rawPostId = getPostId(); 2168 // Ensure postId is a valid number, ignore NaN 2169 const postId = (typeof rawPostId === 'number' && !isNaN(rawPostId)) ? rawPostId : null; 2170 2171 let meter; 2172 try { 2173 meter = JSON.parse(localStorage.getItem(STORAGE_KEY)); 2174 } catch (e) { 2175 meter = null; 2176 } 2177 2178 // Validate meter structure. If posts array is missing or invalid, treat as new/reset. 2179 // Calendar-month reset: compare YYYY-MM of meter start vs now 2180 const nowMonth = new Date().toISOString().slice(0, 7); 2181 const meterMonth = meter && meter.started ? new Date(meter.started).toISOString().slice(0, 7) : null; 2182 if (!meter || !meter.started || !Array.isArray(meter.posts) || nowMonth !== meterMonth) { 2183 // New calendar month, missing data, or corrupted â reset meter 2184 const firstOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime(); 2185 meter = { count: 0, posts: [], started: firstOfMonth }; 2186 } 2187 2188 // Increment for new unique article 2189 if (postId !== null) { 2190 // Use loose check or explicit Number conversion to be safe against type mismatches 2191 const alreadyViewed = meter.posts.some(id => Number(id) === postId); 2192 2193 if (!alreadyViewed) { 2194 meter.count++; 2195 meter.posts.push(postId); 2196 // Cap stored post IDs at 200 2197 if (meter.posts.length > 200) { 2198 meter.posts = meter.posts.slice(-200); 2199 } 2200 } 2201 } 2202 2203 localStorage.setItem(STORAGE_KEY, JSON.stringify(meter)); 2204 return meter.count; 2205 } 2206 2207 /** 2208 * Determine which paywall wall to show based on count (client-side) 2209 * Mirrors server-side determine_wall() logic 2210 */ 2211 function determinePaywallWall(count) { 2212 const walls = config.paywall?.walls || []; 2213 if (!walls.length) return null; 2214 2215 // Filter enabled walls, sort by threshold descending 2216 const enabled = walls 2217 .map((w, i) => ({ ...w, originalIndex: i })) 2218 .filter(w => w.enabled !== false) 2219 .sort((a, b) => (b.threshold || 0) - (a.threshold || 0)); 2220 2221 for (const wall of enabled) { 2222 if (count >= (wall.threshold || 0)) { 2223 return wall; 2224 } 2225 } 2226 return null; 2227 } 2228 2229 /** 2230 * Check paywall for anonymous users entirely client-side 2231 * Uses localStorage meter + wall config from swiftAuthConfig 2232 */ 2233 function checkPaywallAnonymous() { 2234 const count = getAnonymousPaywallMeter(); 2235 const wall = determinePaywallWall(count); 2236 2237 if (!wall) { 2238 console.log('SwiftAuth: Paywall - no wall triggered, count:', count); 2239 return; 2240 } 2241 2242 console.log('SwiftAuth: Paywall - wall triggered, type:', wall.type, 'threshold:', wall.threshold, 'count:', count); 2243 const html = wall.html || null; 2244 const wallType = wall.type || 'hard'; 2245 const shouldBlur = wallType === 'hard'; 2246 2247 showPaywall(html, { access: wallType === 'hard' ? 'blocked' : 'metered' }, wallType, shouldBlur); 2248 } 2249 2250 /** 2251 * Check paywall walls via AJAX (server-side metering with multi-wall support) 2252 * Called for authenticated users without entitlement 2253 */ 2254 async function checkPaywallWalls() { 2255 try { 2256 const magicSession = getMagicToken(); 2257 const body = { 2258 article_url: window.location.href, 2259 post_id: getPostId(), 2260 }; 2261 if (magicSession) { 2262 body.magic_token = magicSession.raw; 2263 } 2264 const response = await ajaxRequest('swift_paywall_check', body); 2265 2266 if (!response.success) { 2267 console.error('SwiftAuth: Paywall wall check failed', response.error); 2268 return; 2269 } 2270 2271 const { paywall, wall_html, wall_type, should_blur } = response; 2272 2273 if (paywall.should_show && wall_html) { 2274 const wt = wall_type || paywall.wall_type; 2275 const paywallCreativeSlot = 'paywall_' + (wt || 'hard') + '_' + ((paywall.wall_index || 0) + 1); 2276 showPaywall(wall_html, paywall, wt, should_blur, paywallCreativeSlot); 2277 2278 // Track paywall shown (client-side) 2279 if (window.SwiftAnalytics) { 2280 if (wt === 'subscription_required') { 2281 window.SwiftAnalytics.trackSubscriptionRequiredShown(paywall.used || 0); 2282 } else { 2283 window.SwiftAnalytics.trackPaywallShown( 2284 paywall.wall_index || 0, 2285 wt, 2286 paywall.remaining || 0, 2287 state.authLevel || 'anonymous' 2288 ); 2289 } 2290 window.SwiftAnalytics.trackWallSelectPromotion(wt, 'Shown', paywallCreativeSlot, !!config.paywall?.enabled); 2291 } 2292 2293 // Record wall impression for authenticated users 2294 if (state.userDataManager && state.authLevel === 'full') { 2295 const wallLevel = paywall.wall_index != null ? paywall.wall_index + 1 : 1; 2296 state.userDataManager.recordWallEvent('paywall', wall_type || paywall.wall_type || 'hard', 'shown', wallLevel); 2297 } 2298 } else if (paywall.access === 'blocked') { 2299 // Blocked but no wall HTML from server - use default 2300 showPaywall(null, paywall, 'hard', true); 2301 } 2302 // If access is 'metered' and no wall to show, user reads freely 2303 } catch (err) { 2304 console.error('SwiftAuth: Paywall wall check error:', err); 2305 // Fail-open: grant access on error 2306 } 2307 } 2308 2309 /** 2310 * Show login required modal or inline message 2311 */ 2312 function showLoginRequired(html) { 2313 // Store current article URL to redirect back after login (only if on an article page) 2314 const article = document.querySelector('.p402_premium'); 2315 if (article) { 2316 setReturnUrl(window.location.href); 2317 } 2318 2319 const displayMode = config.paywall?.displayMode || 'modal'; 2320 const defaultHtml = html || ` 2321 <div class="swift-login-required-message"> 2322 <h2>Sign in to continue reading</h2> 2323 <p>Create a free account or sign in to access this content.</p> 2324 <a href="/authentication/" class="swift-btn-primary">Sign In</a> 2325 </div> 2326 `; 2327 2328 if (displayMode === 'inline') { 2329 // Inline mode: insert login message after first ad, hide remaining content 2330 const article = document.querySelector('.p402_premium'); 2331 if (article) { 2332 const loginWall = document.createElement('div'); 2333 loginWall.className = 'swift-paywall-inline'; 2334 loginWall.innerHTML = defaultHtml; 2335 insertWallAfterFirstAd(article, loginWall, !!config.paywall?.inlineBlur); 2336 } 2337 // Ensure modal container stays hidden in inline mode 2338 const container = document.getElementById('swift-paywall-container'); 2339 if (container) container.style.display = 'none'; 2340 } else if (displayMode === 'bottom-sheet') { 2341 const lrArticle = document.querySelector('.p402_premium'); 2342 let sheet = document.querySelector('.swift-paywall-bottom-sheet'); 2343 if (!sheet) { 2344 sheet = document.createElement('div'); 2345 sheet.className = 'swift-bottom-sheet swift-paywall-bottom-sheet'; 2346 document.body.appendChild(sheet); 2347 } 2348 sheet.setAttribute('data-wall-type', 'hard'); 2349 sheet.innerHTML = `<div class="swift-bottom-sheet-handle"></div><div class="swift-bottom-sheet-content">${splitBottomSheetContent(defaultHtml)}</div>`; 2350 const lrFirstAd = lrArticle ? lrArticle.querySelector('.module-ad') : null; 2351 waitForScrollPastFirstAd(lrFirstAd, function() { 2352 const sentinel = document.createElement('div'); 2353 sentinel.className = 'swift-bottom-sheet-sentinel'; 2354 sentinel.style.cssText = 'height:0;overflow:hidden;margin:0;padding:0;'; 2355 if (lrArticle) insertWallAfterFirstAd(lrArticle, sentinel, true); 2356 sheet.classList.add('swift-bottom-sheet-active'); 2357 }); 2358 const container = document.getElementById('swift-paywall-container'); 2359 if (container) container.style.display = 'none'; 2360 } else { 2361 // Modal mode: show overlay 2362 const container = document.getElementById('swift-paywall-container'); 2363 if (!container) return; 2364 2365 container.innerHTML = `<div class="swift-paywall-modal swift-login-required">${defaultHtml}</div>`; 2366 container.style.display = 'block'; 2367 2368 // Lock content (remove from DOM and prevent scrolling) 2369 lockContent(); 2370 } 2371 } 2372 2373 /** 2374 * Show paywall modal or inline message 2375 * Supports soft walls (dismissable) and hard walls (blocks content) 2376 * 2377 * @param {string|null} html - Wall HTML content 2378 * @param {object} paywallData - Paywall status from server 2379 * @param {string} wallType - 'soft', 'hard', or 'subscription_required' (default: 'hard') 2380 * @param {boolean} shouldBlur - Whether to blur/lock content 2381 * @param {string} creativeSlot - Wall identifier for GA4 select_promotion events 2382 */ 2383 function showPaywall(html, paywallData, wallType, shouldBlur, creativeSlot) { 2384 // Default to hard wall behavior for backward compatibility 2385 wallType = wallType || 'hard'; 2386 shouldBlur = shouldBlur !== undefined ? shouldBlur : (wallType === 'hard' || wallType === 'subscription_required'); 2387 2388 if (wallType === 'hard' || wallType === 'subscription_required') { 2389 window.dispatchEvent(new CustomEvent('swiftauth:paywall-hard', { detail: { wallType } })); 2390 } 2391 2392 // Store current article URL so user returns here after login 2393 const article = document.querySelector('.p402_premium'); 2394 if (article) { 2395 setReturnUrl(window.location.href); 2396 } 2397 2398 const displayMode = config.paywall?.displayMode || 'modal'; 2399 const subscribeUrl = (config.paywall.redirectUrl || '/subscribe/') + '?return_to=' + encodeURIComponent(window.location.href); 2400 // Set cookie so subscribe page knows which article to return to 2401 document.cookie = 'swift_subscribe_return_to=' + encodeURIComponent(window.location.href) + ';path=/;max-age=3600;SameSite=Lax'; 2402 2403 const fallbackHtml = wallType === 'subscription_required' 2404 ? `<div class="swift-subscription-required"> 2405 <h2>Subscription Required</h2> 2406 <p>You've used all your free articles. Subscribe to continue reading.</p> 2407 <a href="${subscribeUrl}" class="swift-btn-primary" data-subscribe-redirect>Subscribe</a> 2408 </div>` 2409 : wallType === 'hard' 2410 ? `<div class="swift-paywall-message"> 2411 <h2>Subscribe to continue reading</h2> 2412 <p>You've used all your free articles this month.</p> 2413 <a href="${subscribeUrl}" class="swift-btn-primary" data-subscribe-redirect>Subscribe Now</a> 2414 </div>` 2415 : `<div class="swift-paywall-message"> 2416 <button data-swift-close type="button">×</button> 2417 <h2>Enjoying our content?</h2> 2418 <p>Subscribe to get unlimited access.</p> 2419 <a href="${subscribeUrl}" class="swift-btn-primary" data-subscribe-redirect>Subscribe Now</a> 2420 </div>`; 2421 2422 const content = html || fallbackHtml; 2423 2424 if (displayMode === 'inline') { 2425 const article = document.querySelector('.p402_premium'); 2426 if (article) { 2427 const isHard = wallType === 'hard' || wallType === 'subscription_required'; 2428 const wallClass = isHard ? 'swift-paywall-inline-hard' : 'swift-paywall-inline-soft'; 2429 2430 let inlineWall = article.querySelector('.' + wallClass); 2431 if (!inlineWall) { 2432 inlineWall = document.createElement('div'); 2433 inlineWall.className = wallClass; 2434 inlineWall.setAttribute('data-wall-type', wallType); 2435 inlineWall.setAttribute('data-wall-name', creativeSlot || ''); 2436 } 2437 inlineWall.innerHTML = content; 2438 2439 // Insert after first .module-ad and hide remaining content 2440 const restoreContent = insertWallAfterFirstAd(article, inlineWall, !!config.paywall?.inlineBlur); 2441 2442 // Add close handler for soft walls 2443 if (!isHard) { 2444 const closeBtn = inlineWall.querySelector('[data-swift-close], [data-dismiss="modal"], .close'); 2445 if (closeBtn) { 2446 closeBtn.addEventListener('click', (e) => { 2447 e.preventDefault(); 2448 console.log('SwiftAuth: Paywall soft wall dismissed'); 2449 if (window.SwiftAnalytics) { 2450 window.SwiftAnalytics.trackPaywallDismissed(paywallData?.wall_index || 0); 2451 window.SwiftAnalytics.trackWallSelectPromotion(paywallData?.wall_type || 'soft', 'ClickClose', creativeSlot, !!config.paywall?.enabled); 2452 } 2453 restoreContent(); 2454 inlineWall.remove(); 2455 }); 2456 } 2457 } 2458 } 2459 const container = document.getElementById('swift-paywall-container'); 2460 if (container) container.style.display = 'none'; 2461 } else if (displayMode === 'bottom-sheet') { 2462 // Bottom-sheet mode: fixed sheet slides up from bottom, content stays visible 2463 const pwArticle = document.querySelector('.p402_premium'); 2464 const isHardPaywall = wallType === 'hard' || wallType === 'subscription_required'; 2465 let restoreContent = function() {}; 2466 2467 let sheet = document.querySelector('.swift-paywall-bottom-sheet'); 2468 if (!sheet) { 2469 sheet = document.createElement('div'); 2470 sheet.className = 'swift-bottom-sheet swift-paywall-bottom-sheet'; 2471 document.body.appendChild(sheet); 2472 } 2473 sheet.setAt
2473tribute('data-wall-type', wallType); 2474 sheet.setAttribute('data-wall-name', creativeSlot || ''); 2475 sheet.innerHTML = `<div class="swift-bottom-sheet-handle"></div><div class="swift-bottom-sheet-content">${splitBottomSheetContent(content)}</div>`; 2476 2477 if (!isHardPaywall) { 2478 const closeBtn = sheet.querySelector('[data-swift-close], [data-dismiss="modal"], .close'); 2479 if (closeBtn) { 2480 closeBtn.addEventListener('click', (e) => { 2481 e.preventDefault(); 2482 console.log('SwiftAuth: Paywall bottom sheet dismissed'); 2483 if (window.SwiftAnalytics) { 2484 window.SwiftAnalytics.trackPaywallDismissed(paywallData?.wall_index || 0); 2485 window.SwiftAnalytics.trackWallSelectPromotion(paywallData?.wall_type || 'soft', 'ClickClose', creativeSlot, !!config.paywall?.enabled); 2486 } 2487 restoreContent(); 2488 hideBottomSheet(sheet); 2489 }); 2490 } 2491 } 2492 2493 const pwFirstAd = pwArticle ? pwArticle.querySelector('.module-ad') : null; 2494 waitForScrollPastFirstAd(pwFirstAd, function() { 2495 if (pwArticle && shouldBlur) { 2496 const sentinel = document.createElement('div'); 2497 sentinel.className = 'swift-bottom-sheet-sentinel'; 2498 sentinel.style.cssText = 'height:0;overflow:hidden;margin:0;padding:0;'; 2499 restoreContent = insertWallAfterFirstAd(pwArticle, sentinel, true); 2500 } 2501 sheet.classList.add('swift-bottom-sheet-active'); 2502 }); 2503 2504 const container = document.getElementById('swift-paywall-container'); 2505 if (container) container.style.display = 'none'; 2506 } else { 2507 // Modal mode 2508 const container = document.getElementById('swift-paywall-container'); 2509 if (!container) return; 2510 2511 container.innerHTML = `<div class="swift-paywall-modal">${content}</div>`; 2512 container.setAttribute('data-wall-type', wallType); 2513 container.setAttribute('data-wall-name', creativeSlot || ''); 2514 2515 const isHardPaywall = wallType === 'hard' || wallType === 'subscription_required'; 2516 if (!isHardPaywall) { 2517 // Soft walls: attach close handler now (container is in DOM, just hidden) 2518 const closeBtn = container.querySelector('[data-swift-close], [data-dismiss="modal"], .close'); 2519 if (closeBtn) { 2520 closeBtn.addEventListener('click', (e) => { 2521 e.preventDefault(); 2522 console.log('SwiftAuth: Paywall soft wall dismissed via close button'); 2523 if (window.SwiftAnalytics) { 2524 window.SwiftAnalytics.trackPaywallDismissed(paywallData?.wall_index || 0); 2525 window.SwiftAnalytics.trackWallSelectPromotion(paywallData?.wall_type || 'soft', 'ClickClose', creativeSlot, !!config.paywall?.enabled); 2526 } 2527 hidePaywall(); 2528 }); 2529 } 2530 2531 // Allow clicking overlay to dismiss soft walls 2532 const overlay = document.querySelector('.swift-wall-overlay'); 2533 if (overlay) { 2534 overlay.addEventListener('click', (e) => { 2535 if (e.target === overlay) { 2536 console.log('SwiftAuth: Paywall soft wall dismissed via overlay click'); 2537 hidePaywall(); 2538 } 2539 }); 2540 } 2541 } 2542 2543 // Defer visibility until user scrolls past the first ad 2544 const paywallArticle = document.querySelector('.p402_premium'); 2545 const firstAd = paywallArticle ? paywallArticle.querySelector('.module-ad') : null; 2546 waitForScrollPastFirstAd(firstAd, function() { 2547 container.style.display = 'block'; 2548 requestAnimationFrame(function() { container.classList.add('swift-wall-active'); }); 2549 if (isHardPaywall) { 2550 if (paywallArticle) paywallArticle.classList.add('swift-content-hidden'); 2551 showOverlay(); 2552 } 2553 }); 2554 } 2555 2556 // Initialize login buttons in the newly added HTML (if any) 2557 initLoginButtons(); 2558 2559 // Hide login prompts for already-authenticated users 2560 if (state.authLevel === 'full') { 2561 document.querySelectorAll('[data-swift-wall-login]').forEach(el => el.remove()); 2562 } 2563 2564 // Add click handlers for subscribe buttons in the paywall 2565 // Match both data-subscribe-redirect and plain links to /subscribe/ 2566 const wallLevel = paywallData?.wall_index != null ? (paywallData.wall_index + 1) : 1; 2567 const paywallContainers = document.querySelectorAll('.swift-paywall-modal, .swift-paywall-inline-hard, .swift-paywall-inline-soft, .swift-paywall, .swift-paywall-bottom-sheet, #swift-paywall-container'); 2568 const subscribeButtons = new Set();
2569 paywallContainers.forEach(container => { 2570 container.querySelectorAll('[data-subscribe-redirect]').forEach(btn => subscribeButtons.add(btn)); 2571 container.querySelectorAll('a[href*="/subscribe"]').forEach(btn => subscribeButtons.add(btn)); 2572 }); 2573 subscribeButtons.forEach(btn => { 2574 btn.addEventListener('click', (e) => { 2575 e.preventDefault(); 2576 // Track subscribe button clicked 2577 if (window.SwiftAnalytics) { 2578 const source = btn.getAttribute('data-source') || 'paywall'; 2579 window.SwiftAnalytics.trackSubscribeButtonClicked(source); 2580 window.SwiftAnalytics.trackWallSelectPromotion(wallType || 'hard', 'ClickSubscribe', creativeSlot, !!config.paywall?.enabled); 2581 } 2582 // Record paywall CTA click for authenticated users 2583 if (state.userDataManager) { 2584 state.userDataManager.recordWallEvent('paywall', wallType || 'hard', 'cta_clicked', wallLevel); 2585 } 2586 // Store conversion attribution 2587 setConversionSource('paywall_' + (wallType || 'hard') + '_' + wallLevel, window.location.href); 2588 window.location.href = subscribeUrl; 2589 }); 2590 }); 2591 } 2592 2593 /** 2594 * Hide paywall container 2595 */ 2596 function hidePaywall() { 2597 const container = document.getElementById('swift-paywall-container'); 2598 if (container) { 2599 container.classList.remove('swift-wall-active'); 2600 container.style.display = 'none'; 2601 container.innerHTML = ''; 2602 } 2603 unlockContent(); 2604 } 2605 2606 /** 2607 * Show meter notice (remaining free articles) 2608 * Disabled - we don't show metering messages, just grant or block access 2609 */ 2610 function showMeterNotice(paywallData) { 2611 // Intentionally empty - no meter notices shown 2612 return; 2613 } 2614 2615 /** 2616 * Update entitlement banner 2617 */ 2618 function updateEntitlementBanner(entitlement) { 2619 const banner = document.getElementById('swift-entitlement-banner'); 2620 if (!banner) return; 2621 2622 if (entitlement.is_active) { 2623 banner.style.display = 'none'; 2624 } else if (entitlement.expires_at) { 2625 const renewUrl = (config.paywall.redirectUrl || '/subscribe/') + '?return_to=' + encodeURIComponent(window.location.href); 2626 banner.innerHTML = `<p>Your subscription expired. <a href="${renewUrl}" data-conversion-source="banner_expired">Renew now</a></p>`; 2627 banner.style.display = 'block'; 2628 const renewLink = banner.querySelector('[data-conversion-source]'); 2629 if (renewLink) { 2630 renewLink.addEventListener('click', () => { 2631 setConversionSource('banner_expired', window.location.href); 2632 }); 2633 } 2634 } 2635 } 2636 2637 // ========================================================================= 2638 // Magic Link Session (localStorage-based, cache-compatible) 2639 // ========================================================================= 2640 2641 const MAGIC_TOKEN_KEY = 'swift_magic_token'; 2642 2643 /** 2644 * Read and validate the magic session token from localStorage. 2645 * Returns the decoded payload if valid and not expired, otherwise null. 2646 */ 2647 function getMagicToken() { 2648 try { 2649 const raw = localStorage.getItem(MAGIC_TOKEN_KEY); 2650 if (!raw) return null; 2651 const [payloadB64] = raw.split('.'); 2652 if (!payloadB64) return null; 2653 const payload = JSON.parse(atob(payloadB64)); 2654 if (!payload || !payload.expiresAt) return null; 2655 if (payload.expiresAt < Math.floor(Date.now() / 1000)) { 2656 localStorage.removeItem(MAGIC_TOKEN_KEY); 2657 return null; 2658 } 2659 return { payload, raw }; 2660 } catch (e) { 2661 return null; 2662 } 2663 } 2664 2665 /** 2666 * Store a magic session HMAC token in localStorage. 2667 */ 2668 function storeMagicToken(tokenString) { 2669 try { 2670 localStorage.setItem(MAGIC_TOKEN_KEY, tokenString); 2671 } catch (e) { 2672 console.warn('SwiftAuth: Could not store magic token in localStorage', e); 2673 } 2674 } 2675 2676 /** 2677 * Detect ?mlt= in URL, call AJAX to validate + mark used, 2678 * store returned HMAC token in localStorage, and update auth state. 2679 * Strips ?mlt= from URL silently on completion or failure. 2680 */ 2681 async function redeemMagicLink() { 2682 const params = new URLSearchParams(window.location.search); 2683 const mlt = params.get('mlt'); 2684 if (!mlt) return; 2685 2686 // Strip ?mlt= from URL immediately (before any async work) 2687 params.delete('mlt'); 2688 const cleanSearch = params.toString(); 2689 const cleanUrl = window.location.pathname + (cleanSearch ? '?' + cleanSearch : '') + window.location.hash; 2690 history.replaceState(null, '', cleanUrl); 2691 2692 try { 2693 const response = await ajaxRequest('swift_magic_redeem', { token: mlt }, 'POST'); 2694 2695 if (!response || !response.success) {
2696 console.log('SwiftAuth: Magic link invalid or already used', response?.error); 2697 return; 2698 } 2699 2700 storeMagicToken(response.token); 2701 state.authLevel = 'lite'; 2702 state.sub = response.sub || null; 2703 console.log('SwiftAuth: Magic session started (lite-login)', { sub: state.sub }); 2704 } catch (err) { 2705 console.log('SwiftAuth: Magic link redemption failed', err); 2706 } 2707 } 2708 2709 // ========================================================================= 2710 // Why Register Modal 2711 // ========================================================================= 2712 2713 /** 2714 * Show the "Why Register" modal 2715 */ 2716 function showWhyRegisterModal() { 2717 console.log('SwiftAuth: showWhyRegisterModal called'); 2718 2719 // Remove existing modal if present 2720 hideWhyRegisterModal(); 2721 2722 const html = config.whyRegisterHtml; 2723 console.log('SwiftAuth: whyRegisterHtml content:', html ? html.substring(0, 100) + '...' : '(empty)'); 2724 2725 if (!html) { 2726 console.warn('SwiftAuth: No Why Register content configured in config.whyRegisterHtml'); 2727 return; 2728 } 2729 2730 // Create modal overlay (z-index 10000, higher than walls at 9999) 2731 const overlay = document.createElement('div'); 2732 overlay.className = 'swift-why-register-overlay'; 2733 overlay.setAttribute('data-swift-why-register-overlay', ''); 2734 2735 // Create modal container 2736 const modal = document.createElement('div'); 2737 modal.className = 'swift-why-register-modal'; 2738 modal.innerHTML = ` 2739 <button type="button" class="swift-why-register-close" data-swift-why-register-close aria-label="Close">×</button> 2740 <div class="swift-why-register-content">${html}</div> 2741 `; 2742 2743 overlay.appendChild(modal); 2744 document.body.appendChild(overlay); 2745 console.log('SwiftAuth: Why Register modal shown'); 2746 2747 // Prevent body scroll 2748 document.body.classList.add('swift-modal-open'); 2749 2750 // Close on overlay click 2751 overlay.addEventListener('click', (e) => { 2752 if (e.target === overlay) { 2753 console.log('SwiftAuth: Why Register modal closed via overlay click');
2754 hideWhyRegisterModal(); 2755 } 2756 }); 2757 2758 // Close on button click 2759 const closeBtn = modal.querySelector('[data-swift-why-register-close]'); 2760 if (closeBtn) { 2761 closeBtn.addEventListener('click', () => { 2762 console.log('SwiftAuth: Why Register modal closed via close button'); 2763 hideWhyRegisterModal(); 2764 }); 2765 } 2766 2767 // Close on escape key 2768 document.addEventListener('keydown', handleWhyRegisterEscape); 2769 } 2770 2771 /** 2772 * Hide the "Why Register" modal 2773 */ 2774 function hideWhyRegisterModal() { 2775 const overlay = document.querySelector('[data-swift-why-register-overlay]'); 2776 if (overlay) { 2777 overlay.remove(); 2778 console.log('SwiftAuth: Why Register modal hidden'); 2779 } 2780 document.body.classList.remove('swift-modal-open'); 2781 document.removeEventListener('keydown', handleWhyRegisterEscape); 2782 } 2783 2784 /** 2785 * Handle escape key for Why Register modal 2786 */ 2787 function handleWhyRegisterEscape(e) { 2788 if (e.key === 'Escape') { 2789 console.log('SwiftAuth: Why Register modal closed via Escape key');
2790 hideWhyRegisterModal(); 2791 } 2792 } 2793 2794 /** 2795 * Initialize Why Register button handlers using event delegation 2796 * This handles dynamically injected content (like paywall HTML) 2797 */ 2798 function initWhyRegisterButtons() { 2799 // Use event delegation on document to catch dynamically added elements 2800 document.addEventListener('click', (e) => { 2801 const trigger = e.target.closest('[data-swift-why-register]'); 2802 if (trigger) { 2803 // If the element has an href, let it navigate naturally (just fire GA event) 2804 if (trigger.href && trigger.tagName === 'A') { 2805 // Fire GA event, let the link navigate 2806 if (window.SwiftAnalytics) { 2807 const wallContainer = trigger.closest('.swift-regwall-modal, .swift-regwall-inline, .swift-paywall-modal, .swift-paywall-inline-hard, .swift-paywall-inline-soft'); 2808 if (wallContainer) { 2809 const wallType = wallContainer.getAttribute('data-wall-type') || 'soft'; 2810 const wallName = wallContainer.getAttribute('data-wall-name') || ''; 2811 window.SwiftAnalytics.trackWallSelectPromotion(wallType, 'ClickMore', wallName, !!config.paywall?.enabled); 2812 } 2813 } 2814 return; // Let the link navigate 2815 } 2816 e.preventDefault(); 2817 console.log('SwiftAuth: Why Register trigger clicked', trigger); 2818 showWhyRegisterModal(); 2819 } 2820 }); 2821 console.log('SwiftAuth: Why Register event delegation initialized'); 2822 } 2823 2824 // ========================================================================= 2825 // Developer Shortcuts 2826 // ========================================================================= 2827 2828 /** 2829 * Check if element is a text input 2830 */ 2831 function isTextInput(element) { 2832 if (!element || !element.tagName) { 2833 return false; 2834 } 2835 const tagName = element.tagName.toLowerCase(); 2836 return tagName === 'input' || tagName === 'textarea' || element.isContentEditable; 2837 } 2838 2839 /** 2840 * Check if this is the session shortcut (Cmd + Shift + 1) 2841 */ 2842 function isSessionShortcut(event) { 2843 if (!event || event.repeat || event.defaultPrevented) { 2844 return false; 2845 } 2846 2847 if (isTextInput(event.target)) { 2848 return false; 2849 } 2850 2851 const key = event.key; 2852 const modifierPressed = event.shiftKey && event.metaKey; 2853 2854 return modifierPressed && key === '1'; 2855 } 2856 2857 /** 2858 * Get current regwall session count from cookie 2859 */ 2860 function getSessionCount() { 2861 const pubID = config.pubID || ''; 2862 const key = pubID + 'regwallMeter'; 2863 2864 try { 2865 const stored = localStorage.getItem(key); 2866 if (!stored) return 0; 2867 2868 const meter = JSON.parse(stored); 2869 return meter.count || 0; 2870 } catch (err) { 2871 return 0; 2872 } 2873 } 2874 2875 /** 2876 * Set session count (updates both regwall and paywall meters) 2877 */ 2878 function setSessionCount(count) { 2879 const pubID = config.pubID || ''; 2880 const firstOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1); 2881 const firstOfMonthSec = Math.floor(firstOfMonth.getTime() / 1000); 2882 const firstOfMonthMs = firstOfMonth.getTime(); 2883 2884 // Update regwall meter (cookie-based) 2885 const regwallKey = pubID + 'regwallMeter'; 2886 const regwallMeter = { 2887 count: count, 2888 started_at: firstOfMonthSec, 2889 viewed_urls: [] 2890 }; 2891 localStorage.setItem(regwallKey, JSON.stringify(regwallMeter)); 2892 2893 // Update paywall meter (localStorage-based for anonymous users) 2894 const paywallKey = 'swift_paywall_meter'; 2895 const paywallMeter = { 2896 count: count, 2897 posts: [], 2898 started: firstOfMonthMs 2899 }; 2900 localStorage.setItem(paywallKey, JSON.stringify(paywallMeter)); 2901 2902 // Also update via AJAX to sync server-side cookie
2903 ajaxRequest('swift_regwall_set_count', { count: count }).catch(err => { 2904 console.log('SwiftAuth: Could not sync session count to server', err); 2905 }); 2906 2907 console.log('SwiftAuth: Session count set to', count, '(both regwall and paywall meters)'); 2908 } 2909 2910 /** 2911 * Handle developer keyboard shortcuts 2912 */ 2913 function handleDeveloperShortcuts(event) { 2914 if (!isSessionShortcut(event)) { 2915 return; 2916 } 2917 2918 event.preventDefault(); 2919 2920 const currentCount = getSessionCount(); 2921 const userInput = window.prompt('Set Swift session count (integer ⥠0)', currentCount); 2922 2923 if (userInput === null) { 2924 return; // User cancelled 2925 } 2926 2927 const parsedCount = parseInt(userInput, 10); 2928 if (Number.isNaN(parsedCount) || parsedCount < 0) { 2929 window.alert('Session count must be a number greater than or equal to 0.'); 2930 return; 2931 } 2932 2933 setSessionCount(parsedCount); 2934 2935 // Reload page to trigger wall re-evaluation with new count 2936 setTimeout(() => { 2937 window.location.reload(); 2938 }, 500); 2939 } 2940 2941 /** 2942 * Initialize developer shortcuts 2943 */ 2944 function initDeveloperShortcuts() { 2945 document.addEventListener('keydown', handleDeveloperShortcuts); 2946 console.log('SwiftAuth: Developer shortcuts initialized (Cmd+Shift+1)'); 2947 } 2948 2949 // ========================================================================= 2950 // Bookmarks (Direct DynamoDB when available, AJAX fallback) 2951 // ========================================================================= 2952 2953 /** 2954 * Add bookmark 2955 * Uses direct DynamoDB when UserDataManager is ready 2956 */ 2957 async function addBookmark(articleUrlOrPostId, metadata = {}) { 2958 if (state.authLevel !== 'full') { 2959 throw new Error('Authentication required'); 2960 } 2961 2962 const meta = getArticleMeta(); 2963 // Support both post ID and URL - if it looks like a number, treat as post ID 2964 const isPostId = /^\d+$/.test(articleUrlOrPostId); 2965 const postId = metadata.postId || (isPostId ? articleUrlOrPostId : null) || articleUrlOrPostId?.replace(/[^a-zA-Z0-9]/g, '-') || getArticleUrl().replace(/[^a-zA-Z0-9]/g, '-'); 2966 const url = metadata.url || (isPostId ? getArticleUrl() : articleUrlOrPostId) || getArticleUrl(); 2967 2968 const bookmarkData = { 2969 postId: postId, 2970 articleTitle: metadata.title || meta.title, 2971 articleUrl: url, 2972 articleImage: metadata.image || meta.image, 2973 }; 2974 2975 // Direct DynamoDB only - no AJAX fallback for user data 2976 if (!isDirectDynamoEnabled()) { 2977 console.error('SwiftAuth: DynamoDB not initialized - cannot add bookmark'); 2978 return { success: false, error: 'DynamoDB not initialized' }; 2979 } 2980 2981 try { 2982 const result = await state.userDataManager.addBookmark(bookmarkData); 2983 return { success: true, bookmarked: true, bookmark: result }; 2984 } catch (error) { 2985 console.error('SwiftAuth: addBookmark failed:', error); 2986 return { success: false, error: error.message }; 2987 } 2988 } 2989 2990 /** 2991 * Remove bookmark 2992 * Uses direct DynamoDB when UserDataManager is ready 2993 */ 2994 async function removeBookmark(articleUrlOrPostId) { 2995 if (state.authLevel !== 'full') { 2996 throw new Error('Authentication required'); 2997 } 2998 2999 // Support both post ID and URL - if it looks like a number, treat as post ID 3000 const isPostId = /^\d+$/.test(articleUrlOrPostId); 3001 const postId = isPostId ? articleUrlOrPostId : (articleUrlOrPostId || getArticleUrl()).replace(/[^a-zA-Z0-9]/g, '-'); 3002 const url = isPostId ? getArticleUrl() : (articleUrlOrPostId || getArticleUrl()); 3003 3004 // Direct DynamoDB only - no AJAX fallback for user data 3005 if (!isDirectDynamoEnabled()) { 3006 console.error('SwiftAuth: DynamoDB not initialized - cannot remove bookmark'); 3007 return { success: false, error: 'DynamoDB not initialized' }; 3008 } 3009 3010 try { 3011 await state.userDataManager.removeBookmark(postId); 3012 return { success: true, bookmarked: false }; 3013 } catch (error) { 3014 console.error('SwiftAuth: removeBookmark failed:', error); 3015 return { success: false, error: error.message }; 3016 } 3017 } 3018 3019 /** 3020 * Toggle bookmark 3021 */ 3022 async function toggleBookmark(articleUrlOrPostId) { 3023 console.log('SwiftAuth: toggleBookmark called with:', articleUrlOrPostId); 3024 3025 const checkResponse = await checkBookmark(articleUrlOrPostId); 3026 console.log('SwiftAuth: checkBookmark response:', checkResponse); 3027 3028 if (checkResponse.bookmarked) { 3029 console.log('SwiftAuth: Removing bookmark...'); 3030 return removeBookmark(articleUrlOrPostId); 3031 } else { 3032 console.log('SwiftAuth: Adding bookmark...'); 3033 return addBookmark(articleUrlOrPostId); 3034 } 3035 } 3036 3037 /** 3038 * Check if article is bookmarked 3039 * Uses direct DynamoDB when UserDataManager is ready 3040 * @param {string} articleUrlOrPostId - Article URL or numeric post ID 3041 */ 3042 async function checkBookmark(articleUrlOrPostId) {
3043 // Support both post ID and URL - if it looks like a number, treat as post ID 3044 const isPostId = /^\d+$/.test(articleUrlOrPostId); 3045 const postId = isPostId ? articleUrlOrPostId : (articleUrlOrPostId || getArticleUrl()).replace(/[^a-zA-Z0-9]/g, '-'); 3046 const url = isPostId ? getArticleUrl() : (articleUrlOrPostId || getArticleUrl()); 3047 3048 console.log('SwiftAuth: checkBookmark called', { articleUrlOrPostId, isPostId, postId, dynamoEnabled: isDirectDynamoEnabled() }); 3049 3050 // Direct DynamoDB only - no AJAX fallback for user data 3051 if (!isDirectDynamoEnabled()) { 3052 console.log('SwiftAuth: DynamoDB not enabled, returning false'); 3053 return { success: true, bookmarked: false, reason: 'not_initialized' }; 3054 } 3055 3056 try { 3057 const isBookmarked = await state.userDataManager.isBookmarked(postId); 3058 console.log('SwiftAuth: isBookmarked result for postId', postId, '=', isBookmarked); 3059 return { success: true, bookmarked: isBookmarked }; 3060 } catch (error) { 3061 console.error('SwiftAuth: checkBookmark failed:', error); 3062 return { success: false, bookmarked: false, error: error.message }; 3063 } 3064 } 3065 3066 /** 3067 * Get all bookmarks 3068 * Uses direct DynamoDB when UserDataManager is ready 3069 */ 3070 async function getBookmarks() { 3071 // Direct DynamoDB only - no AJAX fallback for user data 3072 if (!isDirectDynamoEnabled()) { 3073 return { success: true, bookmarks: [] }; 3074 } 3075 3076 try { 3077 const bookmarks = await state.userDataManager.getBookmarks(); 3078 return { success: true, bookmarks: bookmarks || [] }; 3079 } catch (error) { 3080 console.error('SwiftAuth: getBookmarks failed:', error); 3081 return { success: false, bookmarks: [], error: error.message }; 3082 } 3083 } 3084 3085 // ========================================================================= 3086 // Profile Page 3087 // ========================================================================= 3088 3089 /** 3090 * Get user profile 3091 * Uses direct DynamoDB when UserDataManager is ready 3092 */ 3093 async function getProfile() { 3094 // Direct DynamoDB only - no AJAX fallback for user data 3095 if (!isDirectDynamoEnabled()) { 3096 const diagnostics = getDynamoDBDiagnostics(); 3097 console.error('SwiftAuth: DynamoDB not initialized - cannot get profile'); 3098 console.error('SwiftAuth: Diagnostics:', diagnostics); 3099 console.error('SwiftAuth: Likely cause:', diagnostics.likelyCause); 3100 return { success: false, error: diagnostics.likelyCause, profile: null }; 3101 } 3102 3103 try { 3104 const profile = await state.userDataManager.getProfile(); 3105 const bookmarks = await state.userDataManager.getBookmarks(); 3106 const topics = await state.userDataManager.getFollowedTopics(); 3107 const pageviews = await state.userDataManager.getPageviews(); 3108 const subscription = await state.userDataManager.getSubscription(); 3109 3110 // Debug logging - raw DynamoDB data 3111 console.log('SwiftAuth: Raw DynamoDB profile:', profile); 3112 console.log('SwiftAuth: Raw DynamoDB bookmarks:', bookmarks); 3113 console.log('SwiftAuth: Raw DynamoDB topics:', topics); 3114 console.log('SwiftAuth: Raw DynamoDB pageviews (reading history):', pageviews); 3115 console.log('SwiftAuth: Raw DynamoDB subscription:', subscription); 3116 3117 // Build profile response 3118 const fullProfile = { 3119 ...profile, 3120 bookmarks: bookmarks || [], 3121 followedTopics: topics || [], 3122 readingHistory: pageviews || [], 3123 entitlement: subscription ? { 3124 status: subscription.status, 3125 tier: subscription.tier, 3126 endDate: subscription.endDate, 3127 autoRenew: subscription.autoRenew, 3128 stripeSubscriptionId: subscription.stripeSubscriptionId, 3129 } : null, 3130 localStorageMigrated: profile?.localStorageMigrated || false, 3131 }; 3132 3133 // Store profile in state for other functions to access 3134 state.profile = fullProfile; 3135 3136 // Sync trackReadingHistory preference to localStorage 3137 if (profile && profile.trackReadingHistory !== undefined) { 3138 const pubID = config.pubID || '';
3139 const key = pubID + 'readingHistorySetting'; 3140 localStorage.setItem(key, profile.trackReadingHistory.toString()); 3141 } 3142 3143 return { 3144 success: true, 3145 profile: fullProfile 3146 }; 3147 } catch (error) { 3148 console.error('SwiftAuth: getProfile failed:', error); 3149 return { success: false, error: error.message, profile: null }; 3150 } 3151 } 3152 3153 /** 3154 * Update user profile 3155 * Uses direct DynamoDB when UserDataManager is ready 3156 */ 3157 async function updateProfile(updates) { 3158 // Direct DynamoDB only - no AJAX fallback for user data 3159 if (!isDirectDynamoEnabled()) { 3160 console.error('SwiftAuth: DynamoDB not initialized - cannot update profile'); 3161 return { success: false, error: 'DynamoDB not initialized' }; 3162 } 3163 3164 try { 3165 await state.userDataManager.updateProfile(updates); 3166 return { success: true, updated: updates }; 3167 } catch (error) { 3168 console.error('SwiftAuth: updateProfile failed:', error); 3169 return { success: false, error: error.message }; 3170 } 3171 } 3172 3173 /** 3174 * Get reading history from localStorage (legacy fallback) 3175 */ 3176 function getLocalStorageReadingHistory(limit = 20) { 3177 const pubID = config.pubID || ''; 3178 const key = pubID + 'pageViews'; 3179 3180 try { 3181 const stored = localStorage.getItem(key); 3182 if (!stored) return []; 3183 3184 let pageviews; 3185 try { 3186 pageviews = JSON.parse(stored); 3187 } catch (parseErr) { 3188 // Attempt to salvage truncated JSON (e.g. localStorage hit size limit) 3189 console.warn('SwiftAuth: pageViews JSON is malformed, attempting repair'); 3190 pageviews = repairTruncatedJsonArray(stored); 3191 } 3192 console.log('SwiftAuth: Reading history loaded:', pageviews?.length || 0, 'items'); 3193 3194 if (Array.isArray(pageviews)) { 3195 return pageviews.slice(0, limit); 3196 } 3197 return []; 3198 } catch (err) { 3199 console.warn('SwiftAuth: Could not read localStorage reading history', err); 3200 return []; 3201 } 3202 } 3203 3204 /** 3205 * Attempt to repair a truncated JSON array by finding the last complete object. 3206 * Handles cases where localStorage data was cut off mid-write. 3207 * @param {string} str - Malformed JSON string 3208 * @returns {Array} - Array of successfully parsed objects, or [] 3209 */ 3210 function repairTruncatedJsonArray(str) { 3211 if (!str || !str.startsWith('[')) return []; 3212 3213 // Find the last complete object by looking for the last "}," 3214 // or "}" followed by "]" 3215 const lastCompleteComma = str.lastIndexOf('},'); 3216 const lastCompleteBracket = str.lastIndexOf('}]'); 3217 3218 const cutPoint = Math.max(lastCompleteComma, lastCompleteBracket); 3219 if (cutPoint === -1) return []; 3220 3221 // Slice up to and including the "}", then close the array 3222 const repaired = str.substring(0, cutPoint + 1) + ']'; 3223 3224 try { 3225 const result = JSON.parse(repaired); 3226 if (Array.isArray(result)) { 3227 console.log('SwiftAuth: Repaired truncated JSON, recovered', result.length, 'items'); 3228 return result; 3229 } 3230 } catch (e) { 3231 console.warn('SwiftAuth: JSON repair failed', e); 3232 } 3233 return []; 3234 } 3235 3236 /** 3237 * Get followed topics from localStorage (legacy fallback) 3238 */ 3239 function getLocalStorageFollowedTopics() { 3240 const pubID = config.pubID || ''; 3241 const key = pubID + 'followedTopics'; 3242 3243 try { 3244 const stored = localStorage.getItem(key); 3245 if (!stored) return []; 3246 3247 let topics; 3248 try { 3249 topics = JSON.parse(stored); 3250 } catch (parseErr) { 3251 console.warn('SwiftAuth: followedTopics JSON is malformed, attempting repair'); 3252 topics = repairTruncatedJsonArray(stored); 3253 } 3254 console.log('SwiftAuth: Followed topics loaded:', topics?.length || 0, 'items'); 3255 3256 if (Array.isArray(topics)) { 3257 return topics; 3258 } 3259 return []; 3260 } catch (err) { 3261 console.warn('SwiftAuth: Could not read localStorage followed topics', err); 3262 return []; 3263 } 3264 } 3265 3266 /** 3267 * Get profile data from localStorage (legacy fallback) 3268 */ 3269 function getLocalStorageProfile() { 3270 const pubID = config.pubID || '';
3271 const key = pubID + 'Profile'; 3272 3273 try { 3274 const stored = localStorage.getItem(key); 3275 if (!stored) return null; 3276 3277 return JSON.parse(stored); 3278 } catch (err) { 3279 console.warn('SwiftAuth: Could not read localStorage profile', err); 3280 return null; 3281 } 3282 } 3283 3284 /** 3285 * Save profile data to localStorage 3286 * Skipped if user has been migrated to DynamoDB 3287 */ 3288 function saveLocalStorageProfile(profile) { 3289 // Skip localStorage writes if user has migrated to DynamoDB 3290 if (state.localStorageMigrated) { 3291 return true; 3292 } 3293 3294 const pubID = config.pubID || ''; 3295 const key = pubID + 'Profile'; 3296 3297 try { 3298 localStorage.setItem(key, JSON.stringify(profile)); 3299 return true; 3300 } catch (err) { 3301 console.warn('SwiftAuth: Could not save localStorage profile', err); 3302 return false; 3303 } 3304 } 3305 3306 /** 3307 * Check if site is a paywall site 3308 */ 3309 function isPaywallSite() { 3310 return config.paywall?.enabled === true; 3311 } 3312 3313 /** 3314 * Migrate localStorage data to DynamoDB 3315 * @returns {Promise<object>} Migration result 3316 */ 3317 async function migrateLocalStorageToDb() { 3318 // Direct DynamoDB only - no AJAX 3319 if (!isDirectDynamoEnabled()) { 3320 console.error('SwiftAuth: DynamoDB not initialized - cannot migrate'); 3321 return { success: false, error: 'DynamoDB not initialized' }; 3322 } 3323 3324 const bookmarks = getLocalStorageBookmarks(); 3325 const readingHistory = getLocalStorageReadingHistory(50); 3326 const followedTopics = getLocalStorageFollowedTopics(); 3327 3328 // Check if there's anything to migrate 3329 if (bookmarks.length === 0 && readingHistory.length === 0 && followedTopics.length === 0) { 3330 console.log('SwiftAuth: No localStorage data to migrate'); 3331 // Mark as migrated in profile 3332 await state.userDataManager.updateProfile({ localStorageMigrated: true }); 3333 return { success: true, migrated: false, message: 'Nothing to migrate' }; 3334 } 3335 3336 console.log('SwiftAuth: Migrating localStorage data to DynamoDB', { 3337 bookmarks: bookmarks.length, 3338 readingHistory: readingHistory.length, 3339 followedTopics: followedTopics.length 3340 }); 3341 3342 const counts = { bookmarks: 0, readingHistory: 0, followedTopics: 0 }; 3343 3344 try { 3345 // Migrate bookmarks 3346 for (const bookmark of bookmarks) { 3347 try { 3348 await state.userDataManager.addBookmark(bookmark); 3349 counts.bookmarks++; 3350 } catch (err) { 3351 console.warn('SwiftAuth: Failed to migrate bookmark:', err); 3352 } 3353 } 3354 3355 // Migrate followed topics 3356 for (const topic of followedTopics) { 3357 try { 3358 await state.userDataManager.followTopic(topic); 3359 counts.followedTopics++; 3360 } catch (err) { 3361 console.warn('SwiftAuth: Failed to migrate topic:', err); 3362 } 3363 } 3364 3365 // Migrate reading history 3366 for (const pageview of readingHistory) { 3367 try { 3368 await state.userDataManager.recordPageview(pageview); 3369 counts.readingHistory++; 3370 } catch (err) { 3371 console.warn('SwiftAuth: Failed to migrate pageview:', err); 3372 } 3373 } 3374 3375 // Mark migration complete in profile 3376 await state.userDataManager.updateProfile({ localStorageMigrated: true }); 3377 3378 console.log('SwiftAuth: Migration successful', counts); 3379 // Clear localStorage after successful migration 3380 clearMigratedLocalStorage(); 3381 3382 return { success: true, migrated: true, counts }; 3383 } catch (err) { 3384 console.error('SwiftAuth: Migration failed', err); 3385 return { success: false, error: err.message }; 3386 } 3387 } 3388 3389 /** 3390 * Clear localStorage data after successful migration 3391 */ 3392 function clearMigratedLocalStorage() { 3393 const pubID = config.pubID || ''; 3394 3395 try { 3396 // Clear bookmarks 3397 localStorage.removeItem(pubID + 'bookmarkedPosts'); 3398 // Clear reading history (both legacy key names) 3399 localStorage.removeItem(pubID + 'pageViews'); 3400 localStorage.removeItem(pubID + 'readingHistory'); 3401 localStorage.removeItem(pubID + 'recentlyViewed'); 3402 // Clear followed topics 3403 localStorage.removeItem(pubID + 'followedTopics'); 3404 // Clear profile 3405 localStorage.removeItem(pubID + 'Profile'); 3406 3407 console.log('SwiftAuth: Cleared migrated localStorage data'); 3408 } catch (err) { 3409 console.warn('SwiftAuth: Error clearing localStorage', err); 3410 } 3411 } 3412 3413 /** 3414 * Check if user's data has been migrated to DynamoDB 3415 * @param {object} profile - User profile from server 3416 * @returns {boolean} 3417 */ 3418 function isLocalStorageMigrated(profile) { 3419 return profile?.localStorageMigrated === true; 3420 } 3421 3422 /** 3423 * Get bookmarks from localStorage (legacy fallback) 3424 */ 3425 function getLocalStorageBookmarks() { 3426 const pubID = config.pubID || '';
3427 const key = pubID + 'bookmarkedPosts'; 3428 3429 try { 3430 const stored = localStorage.getItem(key); 3431 console.log('SwiftAuth: localStorage key:', key); 3432 console.log('SwiftAuth: localStorage raw data:', stored); 3433 3434 if (!stored) return []; 3435 3436 let bookmarks; 3437 try { 3438 bookmarks = JSON.parse(stored); 3439 } catch (parseErr) { 3440 console.warn('SwiftAuth: bookmarkedPosts JSON is malformed, attempting repair'); 3441 bookmarks = repairTruncatedJsonArray(stored); 3442 } 3443 console.log('SwiftAuth: Parsed bookmarks:', bookmarks); 3444 3445 // Log first bookmark structure for debugging 3446 if (bookmarks && bookmarks.length > 0) { 3447 console.log('SwiftAuth: First bookmark structure:', JSON.stringify(bookmarks[0], null, 2)); 3448 } 3449 3450 // Handle if bookmarks is an object with nested array 3451 let bookmarkArray = bookmarks; 3452 if (!Array.isArray(bookmarks) && typeof bookmarks === 'object') { 3453 // Try common nested structures 3454 bookmarkArray = bookmarks.items || bookmarks.bookmarks || bookmarks.posts || Object.values(bookmarks); 3455 } 3456 3457 if (Array.isArray(bookmarkArray)) { 3458 return bookmarkArray.map(b => { 3459 // Handle both object format and simple URL strings 3460 if (typeof b === 'string') { 3461 return { url: b, title: '', image: '' }; 3462 } 3463 // Pass through the original object - renderProfile will handle field mapping 3464 return b; 3465 }); 3466 } 3467 return []; 3468 } catch (err) { 3469 console.warn('SwiftAuth: Could not read localStorage bookmarks', err); 3470 return []; 3471 } 3472 } 3473 3474 /** 3475 * Initialize profile page if on profile page 3476 */ 3477 async function initProfilePage() { 3478 const profileRoot = document.getElementById('swift-profile-root'); 3479 if (!profileRoot) { 3480 return; // Not on profile page 3481 } 3482 3483 console.log('SwiftAuth: Profile page detected'); 3484 3485 // Must be fully authenticated (magic sessions cannot access profile) 3486 if (state.authLevel !== 'full') { 3487 renderProfileLoginRequired(profileRoot); 3488 return; 3489 } 3490 3491 // Show loading state 3492 renderProfileLoading(profileRoot); 3493 3494 try { 3495 let response = await getProfile(); 3496 3497 if (!response.success) { 3498 renderProfileError(profileRoot, response.error || 'Failed to load profile'); 3499 return; 3500 } 3501 3502 let profile = response.profile || {}; 3503 3504 // Check if migration is needed 3505 if (!isLocalStorageMigrated(profile)) { 3506 console.log('SwiftAuth: Checking for localStorage data to migrate...'); 3507 3508 // Check if there's localStorage data to migrate 3509 const localBookmarks = getLocalStorageBookmarks(); 3510 const localHistory = getLocalStorageReadingHistory(50); 3511 const localTopics = getLocalStorageFollowedTopics(); 3512 3513 if (localBookmarks.length > 0 || localHistory.length > 0 || localTopics.length > 0) { 3514 console.log('SwiftAuth: Found localStorage data, migrating...'); 3515 3516 // Perform migration 3517 const migrationResult = await migrateLocalStorageToDb(); 3518 3519 if (migrationResult.success && migrationResult.migrated) { 3520 console.log('SwiftAuth: Migration complete, refreshing profile'); 3521 // Re-fetch profile to get the migrated data 3522 response = await getProfile(); 3523 if (response.success) { 3524 profile = response.profile || {}; 3525 } 3526 } 3527 } else { 3528 console.log('SwiftAuth: No localStorage data to migrate'); 3529 // Mark as migrated even if there was nothing to migrate 3530 // This prevents checking localStorage on every page load 3531 if (isDirectDynamoEnabled()) { 3532 await state.userDataManager.updateProfile({ localStorageMigrated: true }); 3533 } 3534 } 3535 } else { 3536 console.log('SwiftAuth: localStorage already migrated, using DynamoDB data only'); 3537 } 3538 3539 // Update global state with migration status and profile data 3540 state.localStorageMigrated = profile.localStorageMigrated === true; 3541 3542 // Filter out invalid/corrupted topics 3543 const rawTopics = profile.followedTopics || []; 3544 const validTopics = rawTopics.filter(topic => { 3545 if (!topic) return false;
3546 const topicId = topic.topicId || topic.id || ''; 3547 const topicName = topic.topicName || topic.name || ''; 3548 return topicId || topicName; 3549 }); 3550 3551 // Filter out invalid/corrupted reading history 3552 const rawHistory = profile.readingHistory || []; 3553 const validHistory = rawHistory.filter(item => { 3554 if (!item) return false; 3555 const url = item.url || item.articleUrl || item.link || item.href || ''; 3556 return !!url; // Must have a URL 3557 }); 3558 3559 // Log if corrupted data was filtered out (data from DynamoDB should be valid) 3560 if (validTopics.length !== rawTopics.length) { 3561 console.warn('SwiftAuth: Filtered', rawTopics.length - validTopics.length, 'corrupted topics'); 3562 } 3563 if (validHistory.length !== rawHistory.length) { 3564 console.warn('SwiftAuth: Filtered', rawHistory.length - validHistory.length, 'corrupted history items'); 3565 } 3566 3567 state.followedTopics = validTopics; 3568 state.readingHistory = validHistory; 3569 3570 // Also filter bookmarks in the profile before rendering 3571 profile.bookmarks = (profile.bookmarks || []).filter(b => { 3572 if (!b) return false; 3573 const url = b.articleUrl || b.bookmarkShortlink || b.url || ''; 3574 return !!url; 3575 }); 3576 3577 renderProfile(profileRoot, profile); 3578 } catch (err) { 3579 console.error('SwiftAuth: Profile load failed', err); 3580 renderProfileError(profileRoot, 'Failed to load profile'); 3581 } 3582 } 3583 3584 /** 3585 * Render login required message 3586 */ 3587 function renderProfileLoginRequired(container) { 3588 container.innerHTML = ` 3589 <div class="swift-profile-login-required text-center py-5"> 3590 <h2>Sign in to view your profile</h2> 3591 <p class="text-muted mb-4">You need to be signed in to access your profile and bookmarks.</p> 3592 <a href="/authentication/" class="btn btn-primary btn-lg">Sign In</a> 3593 </div> 3594 `; 3595 } 3596 3597 /** 3598 * Render loading state 3599 */ 3600 function renderProfileLoading(container) { 3601 injectProfileStyles(); 3602 3603 const shimmerLine = (width) => `<div class="swift-shimmer" style="height:14px;width:${width};border-radius:4px;margin-bottom:0.6rem"></div>`; 3604 const shimmerBlock = (height, width) => `<div class="swift-shimmer" style="height:${height};width:${width || '100%'};border-radius:6px"></div>`; 3605 3606 const profileCard = ` 3607 <div class="swift-profile-section"> 3608 <div class="swift-section-header"> 3609 <div class="swift-shimmer" style="width:20px;height:20px;border-radius:4px"></div> 3610 ${shimmerLine('140px')} 3611 </div> 3612 <div class="swift-section-content"> 3613 <div class="swift-form-row"> 3614 <div class="swift-form-group"> 3615 ${shimmerLine('70px')} 3616 ${shimmerBlock('38px')} 3617 </div> 3618 <div class="swift-form-group"> 3619 ${shimmerLine('70px')} 3620 ${shimmerBlock('38px')} 3621 </div> 3622 </div> 3623 <div class="swift-form-row"> 3624 <div class="swift-form-group"> 3625 ${shimmerLine('50px')} 3626 ${shimmerLine('180px')} 3627 </div> 3628 <div class="swift-form-group"> 3629 ${shimmerLine('80px')} 3630 ${shimmerBlock('38px')} 3631 </div> 3632 </div> 3633 ${shimmerBlock('38px', '120px')} 3634 </div> 3635 </div>`; 3636 3637 const listCard = (titleWidth) => ` 3638 <div class="swift-profile-section"> 3639 <div class="swift-section-header"> 3640 <div class="swift-shimmer" style="width:20px;height:20px;border-radius:4px"></div> 3641 ${shimmerLine(titleWidth)} 3642 </div> 3643 <div class="swift-section-content"> 3644 ${shimmerLine('90%')} 3645 ${shimmerLine('75%')} 3646 ${shimmerLine('60%')} 3647 </div> 3648 </div>`; 3649 3650 container.innerHTML = ` 3651 <div class="swift-profile-container"> 3652 ${profileCard} 3653 ${listCard('120px')} 3654 ${listCard('90px')} 3655 ${listCard('130px')} 3656 </div> 3657 `; 3658 } 3659 3660 /**
3661 * Render error state 3662 */ 3663 function renderProfileError(container, message) { 3664 container.innerHTML = ` 3665 <div class="swift-profile-error text-center py-5"> 3666 <div class="text-danger mb-3"> 3667 <svg width="48" height="48" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 3668 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path> 3669 </svg> 3670 </div> 3671 <h3>Something went wrong</h3> 3672 <p class="text-muted">${message}</p> 3673 <button onclick="SwiftAuth.initProfilePage()" class="btn btn-outline-primary mt-3">Try Again</button> 3674 <div class="mt-2"> 3675 <a href="/logout/" class="btn btn-link text-muted">Log Out</a> 3676 </div> 3677 </div> 3678 `; 3679 } 3680 3681 /** 3682 * Get reading history tracking preference 3683 * Checks state first (loaded from profile), then falls back to localStorage 3684 */ 3685 function getReadingHistoryEnabled() { 3686 // Check if we have the setting from profile state 3687 if (state.profile && state.profile.trackReadingHistory !== undefined) { 3688 return state.profile.trackReadingHistory; 3689 } 3690 // Fall back to localStorage 3691 const pubID = config.pubID || ''; 3692 const key = pubID + 'readingHistorySetting'; 3693 const value = localStorage.getItem(key); 3694 // Default to true, only false if explicitly set to 'false' 3695 return value !== 'false'; 3696 } 3697 3698 /** 3699 * Set reading history tracking preference 3700 * Saves to DynamoDB (for authenticated users) and localStorage 3701 */ 3702 async function setReadingHistoryEnabled(enabled) { 3703 const pubID = config.pubID || ''; 3704 const key = pubID + 'readingHistorySetting'; 3705 // Always save to localStorage as immediate/backup 3706 localStorage.setItem(key, enabled.toString()); 3707 3708 // Update local state 3709 if (state.profile) { 3710 state.profile.trackReadingHistory = enabled; 3711 } 3712 3713 // Save to DynamoDB for authenticated users 3714 if (isDirectDynamoEnabled() && state.authLevel === 'full') { 3715 try { 3716 await state.userDataManager.updateProfile({ trackReadingHistory: enabled }); 3717 console.log('SwiftAuth: Reading history preference saved to profile'); 3718 } catch (err) { 3719 console.error('SwiftAuth: Failed to save reading history preference to profile:', err); 3720 } 3721 } 3722 } 3723 3724 /** 3725 * Remove a single reading history item 3726 * Direct DynamoDB for authenticated users, localStorage for anonymous 3727 * @param {string} url - The article URL 3728 * @param {string} articleId - The article ID for DynamoDB deletion 3729 */ 3730 async function removeReadingHistoryItem(url, articleId) { 3731 // Update local state regardless of storage backend 3732 state.readingHistory = state.readingHistory.filter(item => { 3733 const itemUrl = item.url || item.articleUrl || item.link || item.href || ''; 3734 return itemUrl !== url; 3735 }); 3736 3737 // For authenticated users with DynamoDB, delete from DynamoDB 3738 if (isDirectDynamoEnabled() && state.authLevel === 'full') { 3739 try { 3740 // Use articleId if provided, otherwise derive from URL 3741 const idToDelete = articleId || url.replace(/[^a-zA-Z0-9]/g, '-'); 3742 await state.userDataManager.removePageview(idToDelete); 3743 console.log('SwiftAuth: Reading history item removed from DynamoDB'); 3744 return; 3745 } catch (err) { 3746 console.error('SwiftAuth: Failed to remove reading history item from DynamoDB:', err); 3747 // Continue to try localStorage as fallback 3748 } 3749 } 3750 3751 // localStorage for anonymous users or as fallback 3752 const pubID = config.pubID || '';
3753 const key = pubID + 'pageViews'; 3754 try { 3755 const stored = localStorage.getItem(key); 3756 if (!stored) return; 3757 const history = JSON.parse(stored); 3758 const filtered = history.filter(item => { 3759 const itemUrl = item.url || item.articleUrl || item.link || item.href || ''; 3760 return itemUrl !== url; 3761 }); 3762 localStorage.setItem(key, JSON.stringify(filtered)); 3763 } catch (err) { 3764 console.warn('SwiftAuth: Could not remove reading history item', err); 3765 } 3766 } 3767 3768 /** 3769 * Clear all reading history 3770 * Direct DynamoDB for authenticated users, localStorage for anonymous 3771 */ 3772 async function clearReadingHistory() { 3773 state.readingHistory = []; 3774 3775 // Direct DynamoDB for authenticated users 3776 if (isDirectDynamoEnabled()) { 3777 try { 3778 await state.userDataManager.clearReadingHistory(); 3779 return; 3780 } catch (err) { 3781 console.error('SwiftAuth: clearReadingHistory failed:', err); 3782 throw err; 3783 } 3784 } 3785 3786 // localStorage for anonymous users 3787 const pubID = config.pubID || ''; 3788 const key = pubID + 'pageViews'; 3789 localStorage.removeItem(key); 3790 } 3791 3792 /** 3793 * Record a pageview (for reading history) 3794 * Uses direct DynamoDB when UserDataManager is ready 3795 */ 3796 async function recordPageview(pageviewData) { 3797 // Direct DynamoDB for authenticated users 3798 if (isDirectDynamoEnabled() && state.authLevel === 'full') { 3799 try { 3800 console.log('[PV-DIAG] recordPageview â UserDataManager.recordPageview()', { 3801 userId: state.userDataManager?.getUserId?.(), 3802 pubID: config.pubID, 3803 postId: pageviewData.postId, 3804 articleUrl: pageviewData.articleUrl, 3805 udmUseLocalStorage: state.userDataManager?.useLocalStorage, 3806 udmIsReady: state.userDataManager?.isReady?.(), 3807 }); 3808 await state.userDataManager.recordPageview(pageviewData); 3809 console.log('[PV-DIAG] recordPageview completed (no error thrown)'); 3810 return { success: true }; 3811 } catch (err) { 3812 console.error('[PV-DIAG] recordPageview FAILED:', err); 3813 return { success: false, error: err.message }; 3814 } 3815 } 3816 3817 // localStorage for anonymous users only 3818 if (state.authLevel === 'anonymous') { 3819 const pubID = config.pubID || ''; 3820 const key = pubID + 'pageViews'; 3821 try { 3822 const stored = localStorage.getItem(key); 3823 let history = stored ? JSON.parse(stored) : []; 3824 3825 // Deduplicate by postId or url 3826 const newPostId = pageviewData.postId; 3827 const newUrl = pageviewData.articleUrl || pageviewData.url; 3828 if (newPostId || newUrl) { 3829 history = history.filter(item => { 3830 const itemPostId = item.postId; 3831 const itemUrl = item.articleUrl || item.url; 3832 if (newPostId && itemPostId) { 3833 return itemPostId !== newPostId; 3834 } 3835 if (newUrl && itemUrl) { 3836 return itemUrl !== newUrl; 3837 } 3838 return true; 3839 }); 3840 } 3841 3842 history.unshift({ 3843 ...pageviewData, 3844 viewedAt: pageviewData.viewedAt || new Date().toISOString() 3845 }); 3846 localStorage.setItem(key, JSON.stringify(history.slice(0, 100))); 3847 return { success: true }; 3848 } catch (err) { 3849 console.warn('SwiftAuth: Could not record pageview to localStorage', err); 3850 return { success: false }; 3851 } 3852 } 3853 3854 // Authenticated but DynamoDB not ready - skip recording 3855 return { success: false, error: 'DynamoDB not initialized' }; 3856 } 3857 3858 /**
3859 * Render profile content 3860 */ 3861 function renderProfile(container, profile) { 3862 // Handle empty profile gracefully - user exists but no DynamoDB record yet 3863 const safeProfile = profile || {}; 3864 const bookmarks = safeProfile.bookmarks || []; 3865 const readingHistory = safeProfile.readingHistory || []; 3866 const followedTopics = safeProfile.followedTopics || []; 3867 const entitlement = safeProfile.entitlement; 3868 const trackingEnabled = getReadingHistoryEnabled(); 3869 3870 // Inject profile styles 3871 injectProfileStyles(); 3872 3873 // 1. Profile Edit Section 3874 const profileEditHtml = ` 3875 <div class="swift-profile-section"> 3876 <div class="swift-section-header"> 3877 <svg class="swift-section-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 3878 <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> 3879 <circle cx="12" cy="7" r="4"></circle> 3880 </svg> 3881 <h3>Profile Information</h3> 3882 </div> 3883 <div class="swift-section-content"> 3884 <div class="swift-form-row"> 3885 <div class="swift-form-group"> 3886 <label for="swift-profile-given-name">First Name</label> 3887 <input type="text" class="swift-input" id="swift-profile-given-name" 3888 value="${safeProfile.givenName || ''}" 3889 placeholder="Enter first name"> 3890 </div> 3891 <div class="swift-form-group"> 3892 <label for="swift-profile-family-name">Last Name</label> 3893 <input type="text" class="swift-input" id="swift-profile-family-name" 3894 value="${safeProfile.familyName || ''}" 3895 placeholder="Enter last name"> 3896 </div> 3897 </div> 3898 <div class="swift-form-row"> 3899 <div class="swift-form-group"> 3900 <label>Email</label> 3901 <div class="swift-email-display">${safeProfile.email || 'Not available'}</div> 3902 </div> 3903 <div class="swift-form-group"> 3904 <label for="swift-profile-postal">Postal Code</label> 3905 <div class="swift-postal-wrapper"> 3906 <input type="text" class="swift-input" id="swift-profile-postal" 3907 value="${safeProfile.postalCode || ''}" 3908 placeholder="Enter postal code"> 3909 <button type="button" class="swift-postal-locate" id="swift-locate-postal" 3910 title="Detect my postal code"> 3911 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 3912 <circle cx="12" cy="12" r="4"/> 3913 <line x1="12" y1="2" x2="12" y2="6"/> 3914 <line x1="12" y1="18" x2="12" y2="22"/> 3915 <line x1="2" y1="12" x2="6" y2="12"/> 3916 <line x1="18" y1="12" x2="22" y2="12"/> 3917 </svg> 3918 </button> 3919 </div> 3920 </div> 3921 </div> 3922 <div class="swift-form-actions"> 3923 <button type="button" class="swift-btn swift-btn-primary" id="swift-profile-save"> 3924 Save Changes 3925 </button> 3926 <span id="swift-profile-save-status" class="swift-save-status"></span> 3927 </div> 3928 </div> 3929 </div> 3930 `; 3931 3932 // 1b. Change Password Section 3933 const changePasswordHtml = `
3934 <div class="swift-profile-section"> 3935 <div class="swift-section-header"> 3936 <svg class="swift-section-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 3937 <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect> 3938 <path d="M7 11V7a5 5 0 0 1 10 0v4"></path> 3939 </svg> 3940 <h3>Change Password</h3> 3941 <button type="button" class="swift-btn swift-btn-outline swift-btn-sm" id="swift-change-password-toggle"> 3942 Change Password 3943 </button> 3944 </div> 3945 <div class="swift-section-content" id="swift-change-password-form-wrapper" style="display: none;"> 3946 <p style="margin-bottom: 1rem; font-size: 0.875rem; color: #6c757d;">Don't remember your current password? <a href="/authentication/?view=forgot" style="color: #0d6efd;">Use the forgot password tool</a>.</p> 3947 <div class="swift-form-group"> 3948 <label for="swift-current-password">Current Password</label> 3949 <div class="swift-password-wrapper"> 3950 <input type="password" class="swift-input" id="swift-current-password" placeholder="Enter current password" autocomplete="current-password"> 3951 <button type="button" class="swift-password-toggle" aria-label="Toggle password visibility"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button> 3952 </div> 3953 </div> 3954 <div class="swift-form-group"> 3955 <label for="swift-new-password">New Password</label> 3956 <div class="swift-password-wrapper"> 3957 <input type="password" class="swift-input" id="swift-new-password" placeholder="Enter new password (min. 8 characters)" autocomplete="new-password"> 3958 <button type="button" class="swift-password-toggle" aria-label="Toggle password visibility"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button> 3959 </div> 3960 </div> 3961 <div class="swift-form-group"> 3962 <label for="swift-confirm-password">Confirm New Password</label> 3963 <div class="swift-password-wrapper"> 3964 <input type="password" class="swift-input" id="swift-confirm-password" placeholder="Confirm new password" autocomplete="new-password"> 3965 <button type="button" class="swift-password-toggle" aria-label="Toggle password visibility"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button> 3966 </div> 3967 </div> 3968 <div class="swift-form-actions"> 3969 <button type="button" class="swift-btn swift-btn-primary" id="swift-change-password-submit"> 3970 Update Password 3971 </button> 3972 <button type="button" class="swift-btn swift-btn-outline" id="swift-change-password-cancel"> 3973 Cancel 3974 </button> 3975 <span id="swift-change-password-status" class="swift-save-status"></span> 3976 </div> 3977 </div> 3978 </div> 3979 `; 3980 3981 // 2. Subscription Section (ONLY for paywall sites) 3982 let subscriptionHtml = ''; 3983 if (isPaywallSite()) { 3984 const tier = entitlement?.tier || entitlement?.type || 'free';
3985 const expires = entitlement?.endDate || entitlement?.expires || entitlement?.expires_at || entitlement?.expiresAt; 3986 const autoRenew = entitlement?.autoRenew || entitlement?.auto_renew || false; 3987 const stripeId = entitlement?.stripeSubscriptionId || entitlement?.stripe_subscription_id || ''; 3988 3989 const hasAccess = ['subscriber', 'employee', 'gratis', 'trial'].includes(tier); 3990 const isSubscriber = tier === 'subscriber'; 3991 const isEmployee = tier === 'employee'; 3992 const isGratis = tier === 'gratis'; 3993 const isTrial = tier === 'trial'; 3994 3995 let isExpired = false; 3996 let isInGracePeriod = false; 3997 let daysUntilExpiry = null; 3998 3999 if (expires) { 4000 const expiryDate = new Date(typeof expires === 'number' ? expires * 1000 : expires); 4001 const now = new Date(); 4002 isExpired = now > expiryDate; 4003 4004 if (isExpired) { 4005 const gracePeriodEnd = new Date(expiryDate.getTime() + (3 * 24 * 60 * 60 * 1000)); 4006 isInGracePeriod = now < gracePeriodEnd; 4007 } else { 4008 daysUntilExpiry = Math.ceil((expiryDate - now) / (1000 * 60 * 60 * 24)); 4009 } 4010 } 4011 4012 const formatDate = (dateVal) => { 4013 if (!dateVal) return 'N/A'; 4014 const date = new Date(typeof dateVal === 'number' ? dateVal * 1000 : dateVal); 4015 return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) 4016 + ' at ' + date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); 4017 }; 4018 4019 const getTierInfo = () => { 4020 if (isSubscriber) return { class: 'success', label: 'Subscriber', icon: 'â ' }; 4021 if (isEmployee) return { class: 'info', label: 'Employee', icon: 'â' }; 4022 if (isGratis) return { class: 'secondary', label: 'Complimentary', icon: 'â¦' }; 4023 if (isTrial) return { class: 'warning', label: 'Trial', icon: 'â' }; 4024 return { class: 'default', label: 'Free', icon: 'â' }; 4025 }; 4026 4027 const tierInfo = getTierInfo(); 4028 4029 subscriptionHtml = ` 4030 <div class="swift-profile-section"> 4031 <div class="swift-section-header"> 4032 <svg class="swift-section-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 4033 <rect x="1" y="4" width="22" height="16" rx="2" ry="2"></rect> 4034 <line x1="1" y1="10" x2="23" y2="10"></line> 4035 </svg> 4036 <h3>Subscription</h3> 4037 <span class="swift-tier-badge swift-tier-${tierInfo.class}">${tierInfo.label}</span> 4038 </div> 4039 <div class="swift-section-content"> 4040 <div class="swift-subscription-status ${isInGracePeriod || (isExpired && !isInGracePeriod) ? 'swift-status-expired' : hasAccess ? 'swift-status-active' : 'swift-status-inactive'}"> 4041 ${isInGracePeriod ? ` 4042 <p class="swift-status-message"><strong>Your subscription has expired.</strong></p> 4043 ` : isExpired && !isInGracePeriod ? ` 4044 <p class="swift-status-message"><strong>Your subscription has expired.</strong></p> 4045 ` : hasAccess && daysUntilExpiry !== null && daysUntilExpiry <= 7 ? ` 4046 <p class="swift-status-message">Your subscription expires in <strong>${daysUntilExpiry} day${daysUntilExpiry !== 1 ? 's' : ''}</strong>.</p> 4047 ` : hasAccess ? ` 4048 <p class="swift-status-message">Your subscription is <strong>active</strong>.</p> 4049 ` : ` 4050 <p class="swift-status-message">You do not have an active subscription.</p> 4051 `} 4052 ${expires ? ` 4053 <p class="swift-status-detail">${isExpired ? 'Ended' : (autoRenew ? 'Renews' : 'Expires')}: ${formatDate(expires)}</p> 4054 ` : ''} 4055 </div> 4056 <div class="swift-subscription-actions"> 4057 ${!hasAccess || isExpired ? ` 4058 <a href="/subscribe/" class="swift-btn swift-btn-primary swift-profile-subscribe-btn">Subscribe Now</a> 4059 ` : ''} 4060 ${safeProfile.stripeCustomerId ? ` 4061 <a href="#" class="swift-btn swift-btn-outline swift-manage-sub-btn">Manage Subscription</a> 4062 <a href="#" class="swift-btn swift-btn-outline swift-download-receipts-btn">Download Receipts</a> 4063 ` : ''} 4064 </div> 4065 </div> 4066 </div> 4067 `; 4068 } 4069 4070 // 3. Followed Topics Section 4071 let followedTopicsHtml = ''; 4072 // Filter out empty/invalid topics 4073 const validTopics = followedTopics.filter(topic => { 4074 if (!topic) return false;
4075 const topicId = topic.topicId || topic.id || ''; 4076 const topicName = topic.topicName || topic.name || ''; 4077 return topicId || topicName; // Must have at least an ID or name 4078 }); 4079 4080 if (validTopics.length > 0) { 4081 const topicItems = validTopics.map(topic => { 4082 const topicId = topic.topicId || topic.id || ''; 4083 let topicName = topic.topicName || topic.name || topicId; 4084 4085 // Fix malformed data where topicName equals topicId 4086 if (topicName === topicId && topicId.includes('-')) { 4087 // Convert "category-politics" to "Politics", "author-john-doe" to "John Doe" 4088 const parts = topicId.split('-'); 4089 parts.shift(); // Remove prefix (category, author, series, tag) 4090 topicName = parts.map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); 4091 } 4092 4093 return ` 4094 <span class="swift-topic-tag"> 4095 ${topicName} 4096 <button type="button" class="swift-tag-remove" data-swift-unfollow-topic="${topicId}" aria-label="Unfollow"> 4097 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 4098 <line x1="18" y1="6" x2="6" y2="18"></line> 4099 <line x1="6" y1="6" x2="18" y2="18"></line> 4100 </svg> 4101 </button> 4102 </span> 4103 `; 4104 }).join(''); 4105 4106 followedTopicsHtml = ` 4107 <div class="swift-profile-section"> 4108 <div class="swift-section-header"> 4109 <svg class="swift-section-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 4110 <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path> 4111 <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path> 4112 </svg> 4113 <h3>Followed Topics</h3> 4114 <span class="swift-count-badge">${validTopics.length}</span> 4115 </div> 4116 <div class="swift-section-content"> 4117 <div class="swift-topics-grid"> 4118 ${topicItems} 4119 </div> 4120 </div> 4121 </div> 4122 `; 4123 } else { 4124 followedTopicsHtml = ` 4125 <div class="swift-profile-section"> 4126 <div class="swift-section-header"> 4127 <svg class="swift-section-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 4128 <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path> 4129 <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path> 4130 </svg> 4131 <h3>Followed Topics</h3> 4132 </div> 4133 <div class="swift-section-content"> 4134 <p class="swift-empty-message">You're not following any topics yet. Follow topics to get personalized content recommendations.</p> 4135 </div> 4136 </div> 4137 `; 4138 } 4139 4140 // 4. Bookmarks Section 4141 let bookmarksHtml = ''; 4142 if (bookmarks.length > 0) { 4143 // Deduplicate bookmarks by articleId/postId - keep the most recent one 4144 const seenIds = new Set(); 4145 const deduplicatedBookmarks = bookmarks 4146 .sort((a, b) => { 4147 // Sort by bookmarkedAt descending (most recent first) 4148 const dateA = new Date(a.bookmarkedAt || 0); 4149 const dateB = new Date(b.bookmarkedAt || 0); 4150 return dateB - dateA; 4151 }) 4152 .filter(b => { 4153 const id = b.postId || b.articleId || b.articleUrl || ''; 4154 if (!id || seenIds.has(id)) return false;
4155 seenIds.add(id); 4156 return true; 4157 }); 4158 4159 const bookmarkItems = deduplicatedBookmarks.map((b, index) => { 4160 const url = b.articleUrl || b.bookmarkShortlink || b.url || ''; 4161 const title = b.articleTitle || b.bookmarkTitle || b.title || url; 4162 const postId = b.postId || b.articleId || ''; 4163 // Store the SK for proper deletion of old-format records 4164 const sk = b.SK || ''; 4165 4166 if (!url) return ''; 4167 4168 return ` 4169 <div class="swift-list-item" ${postId ? `data-post-id="${postId}"` : ''} ${sk ? `data-sk="${sk}"` : ''}> 4170 <a href="${url}" class="swift-item-title">${title}</a> 4171 <button type="button" class="swift-item-remove" data-swift-remove-bookmark="${url}" ${postId ? `data-post-id="${postId}"` : ''} ${sk ? `data-sk="${sk}"` : ''} aria-label="Remove"> 4172 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 4173 <polyline points="3 6 5 6 21 6"></polyline> 4174 <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path> 4175 </svg> 4176 </button> 4177 </div> 4178 `; 4179 }).filter(html => html).join(''); 4180 4181 bookmarksHtml = ` 4182 <div class="swift-profile-section"> 4183 <div class="swift-section-header"> 4184 <svg class="swift-section-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 4185 <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path> 4186 </svg> 4187 <h3>Bookmarks</h3> 4188 <span class="swift-count-badge">${deduplicatedBookmarks.length}</span> 4189 </div> 4190 <div class="swift-section-content"> 4191 <div class="swift-list"> 4192 ${bookmarkItems} 4193 </div> 4194 </div> 4195 </div> 4196 `; 4197 } else { 4198 bookmarksHtml = ` 4199 <div class="swift-profile-section"> 4200 <div class="swift-section-header"> 4201 <svg class="swift-section-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 4202 <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path> 4203 </svg> 4204 <h3>Bookmarks</h3> 4205 </div> 4206 <div class="swift-section-content"> 4207 <p class="swift-empty-message">You haven't saved any articles yet. Click the bookmark icon on articles to save them for later.</p> 4208 </div> 4209 </div> 4210 `; 4211 } 4212 4213 // 5. Reading History Section with toggle and delete 4214 let readingHistoryHtml = ''; 4215 const historyItems = readingHistory.slice(0, 15).map((item, index) => { 4216 const url = item.articleUrl || item.url || ''; 4217 const title = item.articleTitle || item.title || url; 4218 const viewedAt = item.viewedAt || item.timestamp || item.date || ''; 4219 // Get articleId for DynamoDB deletion - same logic as when storing 4220 const articleId = item.postId || item.articleId || url.replace(/[^a-zA-Z0-9]/g, '-'); 4221 4222 if (!url) return ''; 4223 4224 let dateStr = ''; 4225 if (viewedAt) { 4226 try { 4227 const date = new Date(viewedAt); 4228 dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); 4229 } catch (e) { 4230 dateStr = ''; 4231 } 4232 } 4233 4234 return ` 4235 <div class="swift-list-item swift-history-item-row"> 4236 <a href="${url}" class="swift-item-title">${title}</a> 4237 <div class="swift-item-meta"> 4238 ${dateStr ? `<span class="swift-item-date">${dateStr}</span>` : ''} 4239 <button type="button" class="swift-item-remove" data-swift-remove-history="${url}" data-article-id="${articleId}" aria-label="Remove"> 4240 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 4241 <line x1="18" y1="6" x2="6" y2="18"></line> 4242 <line x1="6" y1="6" x2="18" y2="18"></line> 4243 </svg> 4244 </button> 4245 </div> 4246 </div> 4247 `; 4248 }).filter(html => html).join(''); 4249 4250 readingHistoryHtml = `
4251 <div class="swift-profile-section"> 4252 <div class="swift-section-header"> 4253 <svg class="swift-section-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 4254 <circle cx="12" cy="12" r="10"></circle> 4255 <polyline points="12 6 12 12 16 14"></polyline> 4256 </svg> 4257 <h3>Reading History</h3> 4258 </div> 4259 <div class="swift-section-content"> 4260 <div class="swift-history-controls"> 4261 <label class="swift-toggle"> 4262 <input type="checkbox" id="swift-history-toggle" ${trackingEnabled ? 'checked' : ''}> 4263 <span class="swift-toggle-slider"></span> 4264 <span class="swift-toggle-label">Track reading history</span> 4265 </label> 4266 ${readingHistory.length > 0 ? ` 4267 <button type="button" class="swift-btn swift-btn-text swift-btn-danger" id="swift-clear-history"> 4268 Clear All History 4269 </button> 4270 ` : ''} 4271 </div> 4272 ${readingHistory.length > 0 ? ` 4273 <div class="swift-list swift-history-list"> 4274 ${historyItems} 4275 </div> 4276 ` : ` 4277 <p class="swift-empty-message">Your reading history will appear here as you browse articles.</p> 4278 `} 4279 </div> 4280 </div> 4281 `; 4282 4283 // 6. Purchased Photos Section (only when photo sales enabled) 4284 let purchasedPhotosHtml = ''; 4285 if (config.photoSales && config.photoSales.enabled) { 4286 purchasedPhotosHtml = ` 4287 <div class="swift-profile-section" id="swift-purchased-photos-section"> 4288 <div class="swift-section-header"> 4289 <svg class="swift-section-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 4290 <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/> 4291 <circle cx="12" cy="13" r="4"/> 4292 </svg> 4293 <h3>Purchased Photos</h3> 4294 </div> 4295 <div class="swift-section-content"> 4296 <p class="swift-empty-message">Loading...</p> 4297 </div> 4298 </div> 4299 `; 4300 } 4301 4302 // Render the full profile 4303 container.innerHTML = ` 4304 <div class="swift-profile-container"> 4305 ${profileEditHtml} 4306 ${changePasswordHtml} 4307 ${subscriptionHtml} 4308 ${followedTopicsHtml} 4309 ${bookmarksHtml} 4310 ${readingHistoryHtml} 4311 ${purchasedPhotosHtml} 4312 4313 <div class="swift-profile-footer"> 4314 <button type="button" id="swift-sign-out" class="swift-btn swift-btn-outline">Sign Out</button> 4315 <button type="button" id="swift-delete-account" class="swift-btn swift-btn-danger-outline">Delete Account</button> 4316 </div> 4317 </div> 4318 `; 4319 4320 // Attach event handlers 4321 attachProfileEventHandlers(container, safeProfile); 4322 4323 // Load purchased photos asynchronously 4324 if (config.photoSales && config.photoSales.enabled) { 4325 loadPurchasedPhotos(container); 4326 } 4327 } 4328 4329 /** 4330 * Inject profile page styles 4331 */ 4332 function injectProfileStyles() { 4333 if (document.getElementById('swift-profile-styles')) return; 4334 4335 const styles = ` 4336 .swift-profile-container { 4337 max-width: 800px; 4338 margin: 0 auto; 4339 padding: 0 1rem; 4340 } 4341 4342 .swift-profile-section { 4343 background: #fff; 4344 border-radius: 12px; 4345 box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06); 4346 margin-bottom: 1.5rem; 4347 overflow: hidden; 4348 } 4349 4350 .swift-section-header { 4351 display: flex; 4352 align-items: center; 4353 gap: 0.75rem; 4354 padding: 1rem 1.25rem; 4355 border-bottom: 1px solid #eee; 4356 background: #fafafa; 4357 } 4358 4359 .swift-section-header h3 { 4360 font-size: 1rem; 4361 font-weight: 600; 4362 margin: 0; 4363 color: #333; 4364 } 4365 4366 .swift-section-icon {
4367 width: 20px; 4368 height: 20px; 4369 color: #666; 4370 flex-shrink: 0; 4371 } 4372 4373 .swift-section-content { 4374 padding: 1.25rem; 4375 } 4376 4377 .swift-count-badge { 4378 background: #e9ecef; 4379 color: #495057; 4380 font-size: 0.75rem; 4381 font-weight: 600; 4382 padding: 0.2rem 0.5rem; 4383 border-radius: 10px; 4384 margin-left: auto; 4385 } 4386 4387 .swift-tier-badge { 4388 font-size: 0.7rem; 4389 font-weight: 600; 4390 padding: 0.25rem 0.6rem; 4391 border-radius: 4px; 4392 margin-left: auto; 4393 text-transform: uppercase; 4394 letter-spacing: 0.5px; 4395 } 4396 4397 .swift-tier-success { background: #d4edda; color: #155724; } 4398 .swift-tier-info { background: #d1ecf1; color: #0c5460; } 4399 .swift-tier-warning { background: #fff3cd; color: #856404; } 4400 .swift-tier-secondary { background: #e2e3e5; color: #383d41; } 4401 .swift-tier-default { background: #f8f9fa; color: #6c757d; } 4402 4403 /* Form styles */ 4404 .swift-form-row { 4405 display: grid; 4406 grid-template-columns: 1fr 1fr; 4407 gap: 1rem; 4408 } 4409 4410 @media (max-width: 576px) { 4411 .swift-form-row { 4412 grid-template-columns: 1fr; 4413 } 4414 } 4415 4416 .swift-form-group { 4417 margin-bottom: 1rem; 4418 } 4419 4420 .swift-form-group label { 4421 display: block; 4422 font-size: 0.8rem; 4423 font-weight: 500; 4424 color: #666; 4425 margin-bottom: 0.4rem; 4426 } 4427 4428 .swift-input { 4429 width: 100%; 4430 padding: 0.6rem 0.8rem; 4431 font-size: 0.95rem; 4432 border: 1px solid #ddd; 4433 border-radius: 6px; 4434 transition: border-color 0.2s, box-shadow 0.2s; 4435 } 4436 4437 .swift-input:focus { 4438 outline: none; 4439 border-color: #0066cc; 4440 box-shadow: 0 0 0 3px rgba(0,102,204,0.1); 4441 } 4442 4443 .swift-email-display { 4444 padding: 0.6rem 0; 4445 font-size: 0.95rem; 4446 color: #333; 4447 } 4448 4449 .swift-form-actions { 4450 display: flex; 4451 align-items: center; 4452 gap: 1rem; 4453 margin-top: 0.5rem; 4454 } 4455 4456 .swift-save-status { 4457 font-size: 0.85rem; 4458 } 4459 4460 /* Button styles */ 4461 .swift-btn { 4462 display: inline-flex; 4463 align-items: center; 4464 justify-content: center; 4465 gap: 0.5rem; 4466 padding: 0.6rem 1.2rem; 4467 font-size: 0.9rem; 4468 font-weight: 500; 4469 border-radius: 6px; 4470 border: none; 4471 cursor: pointer; 4472 transition: all 0.2s; 4473 text-decoration: none; 4474 } 4475 4476 .swift-btn-primary { 4477 background: #0066cc; 4478 color: #fff; 4479 } 4480 4481 .swift-btn-primary:hover { 4482 background: #0052a3; 4483 } 4484 4485 .swift-btn-outline { 4486 background: transparent; 4487 border: 1px solid #ddd; 4488 color: #333; 4489 } 4490 4491 .swift-btn-outline:hover { 4492 background: #f5f5f5; 4493 border-color: #ccc; 4494 } 4495 4496 .swift-btn-text { 4497 background: transparent; 4498 padding: 0.4rem 0.8rem; 4499 color: #666; 4500 } 4501 4502 .swift-btn-text:hover { 4503 background: #f5f5f5; 4504 } 4505 4506 .swift-btn-danger { 4507 color: #dc3545; 4508 } 4509 4510 .swift-btn-danger:hover { 4511 background: #fff5f5; 4512 } 4513 4514 .swift-btn-danger-outline { 4515 background: transparent; 4516 border: 1px solid #dc3545; 4517 color: #dc3545; 4518 margin-left: 1rem; 4519 } 4520 4521 .swift-btn-danger-outline:hover { 4522 background: #dc3545; 4523 color: #fff; 4524 } 4525 4526 /* Topics grid */ 4527 .swift-topics-grid { 4528 display: flex; 4529 flex-wrap: wrap; 4530 gap: 0.5rem; 4531 } 4532 4533 .swift-topic-tag { 4534 display: inline-flex; 4535 align-items: center; 4536 gap: 0.4rem; 4537 background: #e9ecef; 4538 color: #495057; 4539 padding: 0.4rem 0.6rem 0.4rem 0.8rem; 4540 border-radius: 20px; 4541 font-size: 0.85rem; 4542 font-weight: 500; 4543 } 4544 4545 .swift-tag-remove { 4546 display: flex; 4547 align-items: center; 4548 justify-content: center; 4549 width: 18px; 4550 height: 18px; 4551 padding: 0; 4552 border: none; 4553 background: rgba(0,0,0,0.1); 4554 border-radius: 50%; 4555 cursor: pointer; 4556 transition: background 0.2s; 4557 } 4558 4559 .swift-tag-remove:hover { 4560 background: rgba(0,0,0,0.2); 4561 } 4562 4563 .swift-tag-remove svg { 4564 width: 10px; 4565 height: 10px; 4566 color: #666; 4567 } 4568 4569 /* List items */ 4570 .swift-list { 4571 display: flex; 4572 flex-direction: column; 4573 } 4574 4575 .swift-list-item { 4576 display: flex; 4577 align-items: center; 4578 justify-content: space-between; 4579 gap: 1rem; 4580 padding: 0.75rem 0; 4581 border-bottom: 1px solid #eee; 4582 } 4583 4584 .swift-list-item:last-child { 4585 border-bottom: none; 4586 } 4587 4588 .swift-item-title { 4589 flex: 1; 4590 color: #333; 4591 text-decoration: none; 4592 font-size: 0.95rem; 4593 line-height: 1.4; 4594 overflow: hidden; 4595 text-overflow: ellipsis; 4596 display: -webkit-box; 4597 -webkit-line-clamp: 2; 4598 -webkit-box-orient: vertical; 4599 } 4600 4601 .swift-item-title:hover { 4602 color: #0066cc; 4603 } 4604 4605 .swift-item-meta { 4606 display: flex; 4607 align-items: center; 4608 gap: 0.75rem; 4609 flex-shrink: 0; 4610 } 4611 4612 .swift-item-date { 4613 font-size: 0.8rem; 4614 color: #999; 4615 white-space: nowrap; 4616 } 4617 4618 .swift-item-remove { 4619 display: flex; 4620 align-items: center; 4621 justify-content: center; 4622 width: 28px; 4623 height: 28px; 4624 padding: 0; 4625 border: none; 4626 background: transparent; 4627 border-radius: 4px; 4628 cursor: pointer; 4629 transition: background 0.2s; 4630 flex-shrink: 0; 4631 } 4632 4633 .swift-item-remove:hover { 4634 background: #fee; 4635 } 4636 4637 .swift-item-remove svg { 4638 width: 16px; 4639 height: 16px; 4640 color: #999; 4641 } 4642 4643 .swift-item-remove:hover svg { 4644 color: #dc3545; 4645 } 4646 4647 .swift-list-more { 4648 font-size: 0.8rem; 4649 color: #999; 4650 margin: 0.75rem 0 0; 4651 padding-top: 0.75rem; 4652 border-top: 1px solid #eee; 4653 } 4654 4655 /* History controls */ 4656 .swift-history-controls { 4657 display: flex; 4658 align-items: center; 4659 justify-content: space-between; 4660 margin-bottom: 1rem; 4661 padding-bottom: 1rem; 4662 border-bottom: 1px solid #eee; 4663 } 4664 4665 .swift-toggle { 4666 display: flex; 4667 align-items: center; 4668 gap: 0.75rem; 4669 cursor: pointer; 4670 } 4671 4672 .swift-toggle input { 4673 display: none; 4674 } 4675 4676 .swift-toggle-slider { 4677 position: relative; 4678 width: 40px; 4679 height: 22px; 4680 background: #ccc; 4681 border-radius: 22px; 4682 transition: background 0.3s; 4683 } 4684 4685 .swift-toggle-slider::before { 4686 content: ''; 4687 position: absolute; 4688 top: 2px; 4689 left: 2px; 4690 width: 18px; 4691 height: 18px; 4692 background: #fff; 4693 border-radius: 50%; 4694 transition: transform 0.3s; 4695 } 4696 4697 .swift-toggle input:checked + .swift-toggle-slider { 4698 background: #0066cc; 4699 } 4700 4701 .swift-toggle input:checked + .swift-toggle-slider::before { 4702 transform: translateX(18px); 4703 } 4704 4705 .swift-toggle-label { 4706 font-size: 0.9rem; 4707 color: #333; 4708 } 4709 4710 /* Empty state */ 4711 .swift-empty-message { 4712 color: #999; 4713 font-size: 0.9rem; 4714 margin: 0; 4715 } 4716 4717 /* Subscription status */ 4718 .swift-subscription-status { 4719 padding: 1rem; 4720 border-radius: 8px; 4721 margin-bottom: 1rem; 4722 } 4723 4724 .swift-status-active { 4725 background: #d4edda; 4726 } 4727 4728 .swift-status-expired { 4729 background: #f8d7da; 4730 } 4731 4732 .swift-status-inactive { 4733 background: #f8f9fa; 4734 } 4735 4736 .swift-status-message { 4737 margin: 0 0 0.25rem; 4738 font-size: 0.95rem; 4739 } 4740 4741 .swift-status-detail { 4742 margin: 0; 4743 font-size: 0.85rem; 4744 opacity: 0.8; 4745 } 4746 4747 .swift-subscription-actions { 4748 display: flex; 4749 gap: 0.75rem; 4750 } 4751 4752 /* Footer */ 4753 .swift-profile-footer { 4754 padding: 1.5rem 0; 4755 text-align: center; 4756 } 4757 4758 /* Shimmer skeleton */ 4759 @keyframes swift-shimmer { 4760 0% { background-position: -400px 0; } 4761 100% { background-position: 400px 0; } 4762 } 4763 4764 .swift-shimmer { 4765 background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); 4766 background-size: 800px 100%; 4767 animation: swift-shimmer 1.5s infinite ease-in-out; 4768 } 4769 4770 .swift-delete-modal-overlay { 4771 position: fixed; 4772 inset: 0; 4773 background: rgba(0, 0, 0, 0.5); 4774 display: flex; 4775 align-items: center; 4776 justify-content: center; 4777 z-index: 100000; 4778 } 4779 .swift-delete-modal { 4780 background: #fff; 4781 border-radius: 8px; 4782 padding: 32px; 4783 max-width: 440px; 4784 width: 90%; 4785 box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2); 4786 } 4787 .swift-delete-modal h3 { 4788 margin: 0 0 12px; 4789 font-size: 1.25rem; 4790 } 4791 .swift-delete-modal p { 4792 margin: 0 0 24px; 4793 color: #555; 4794 line-height: 1.5; 4795 } 4796 .swift-delete-modal-actions { 4797 display: flex; 4798 gap: 12px; 4799 justify-content: flex-end; 4800 } 4801 .swift-btn-danger { 4802 background: #dc3545; 4803 color: #fff; 4804 border: 1px solid #dc3545; 4805 } 4806 .swift-btn-danger:hover { 4807 background: #c82333; 4808 } 4809 `; 4810 4811 const styleEl = document.createElement('style'); 4812 styleEl.id = 'swift-profile-styles'; 4813 styleEl.textContent = styles; 4814 document.head.appendChild(styleEl); 4815 } 4816 4817 /** 4818 * Detect postal code (and full location data) via browser geolocation + Google Maps reverse geocoding. 4819 * Returns { postalCode, city, county, state, country, latitude, longitude, formattedAddress, locationUpdatedAt } 4820 */ 4821 async function detectPostalCode() { 4822 // Get coordinates from browser 4823 const position = await new Promise((resolve, reject) => { 4824 if (!navigator.geolocation) { 4825 reject(new Error('Geolocation is not supported by this browser')); 4826 return; 4827 } 4828 navigator.geolocation.getCurrentPosition(resolve, reject, { 4829 enableHighAccuracy: true, 4830 timeout: 30000, 4831 maximumAge: 300000 4832 }); 4833 }); 4834 4835 const { latitude, longitude } = position.coords; 4836 4837 // Reverse geocode via Google Maps 4838 const apiKey = window.swiftAuthConfig?.googleMaps?.apiKey; 4839 if (!apiKey) { 4840 throw new Error('Google Maps API key not configured. Please contact support.'); 4841 } 4842
4843 const url = new URL('https://maps.googleapis.com/maps/api/geocode/json'); 4844 url.searchParams.set('latlng', `${latitude},${longitude}`); 4845 url.searchParams.set('key', apiKey); 4846 4847 const response = await fetch(url.toString()); 4848 if (!response.ok) { 4849 throw new Error(`Reverse geocoding failed: ${response.statusText}`); 4850 } 4851 4852 const data = await response.json(); 4853 if (data.status !== 'OK') { 4854 throw new Error(`Reverse geocoding error: ${data.status}`); 4855 } 4856 4857 const result = data.results?.[0]; 4858 if (!result) { 4859 throw new Error('No results found for location'); 4860 } 4861 4862 const components = {}; 4863 result.address_components?.forEach(component => { 4864 const types = component.types; 4865 if (types.includes('postal_code')) components.postalCode = component.long_name; 4866 if (types.includes('locality')) components.city = component.long_name; 4867 if (types.includes('administrative_area_level_2')) components.county = component.long_name; 4868 if (types.includes('administrative_area_level_1')) components.state = component.long_name; 4869 if (types.includes('country')) components.country = component.long_name; 4870 }); 4871 4872 return { 4873 postalCode: components.postalCode || null, 4874 city: components.city || null, 4875 county: components.county || null, 4876 state: components.state || null, 4877 country: components.country || null, 4878 latitude, 4879 longitude, 4880 formattedAddress: result.formatted_address || null, 4881 locationUpdatedAt: new Date().toISOString() 4882 }; 4883 } 4884 4885 /** 4886 * Get a user-friendly error message from a geolocation error 4887 */ 4888 function getGeoErrorMessage(error) { 4889 if (error.code === 1) { 4890 return "No problem! You can enter your postal code manually instead."; 4891 } else if (error.code === 2) { 4892 if (!window.isSecureContext && window.location.hostname !== 'localhost') { 4893 return "Location requires HTTPS. Please enter your postal code manually."; 4894 } 4895 const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; 4896 if (isMac) { 4897 return "Location unavailable. On Mac, check System Settings â Privacy & Security â Location Services. Or enter your postal code manually."; 4898 } 4899 return "We couldn't detect your location. Please enter your postal code manually."; 4900 } else if (error.code === 3) { 4901 return "Location request timed out. Please try again or enter your postal code manually."; 4902 } else if (error.message) { 4903 return error.message; 4904 } 4905 return "Unable to get your location. Please enter your postal code manually."; 4906 } 4907 4908 /** 4909 * Attach event handlers for profile page interactions 4910 */ 4911 /** 4912 * Load and render purchased photos into the profile section 4913 */ 4914 async function loadPurchasedPhotos(container) { 4915 const section = container.querySelector('#swift-purchased-photos-section'); 4916 if (!section) return; 4917 4918 const content = section.querySelector('.swift-section-content'); 4919 const termsUrl = config.photoSales.licenseTermsUrl || '/photo-license-terms/'; 4920 4921 try { 4922 const idToken = getIdTokenSync(); 4923 if (!idToken) { 4924 content.innerHTML = '<p class="swift-empty-message">Sign in to view purchases.</p>'; 4925 return; 4926 } 4927 4928 const nonce = await new Promise((resolve) => { 4929 const xhr = new XMLHttpRequest(); 4930 xhr.open('GET', ajax.ajaxUrl + '?action=swift_get_nonce'); 4931 xhr.withCredentials = true; 4932 xhr.onload = () => { 4933 try { resolve(JSON.parse(xhr.responseText).data.nonce); } 4934 catch(e) { resolve(null); } 4935 }; 4936 xhr.onerror = () => resolve(null); 4937 xhr.send(); 4938 }); 4939 4940 if (!nonce) { 4941 content.innerHTML = '<p class="swift-empty-message">Failed to load purchases.</p>'; 4942 return; 4943 } 4944 4945 const formData = new FormData(); 4946 formData.append('action', 'swift_photo_purchases'); 4947 formData.append('nonce', nonce); 4948 4949 const response = await fetch(ajax.ajaxUrl, { 4950 method: 'POST', 4951 body: formData, 4952 credentials: 'same-origin', 4953 headers: { 'Authorization': 'Bearer ' + idToken }, 4954 }); 4955 4956 const result = await response.json(); 4957 4958 if (!result.success || !result.licenses || result.licenses.length === 0) { 4959 content.innerHTML = '<p class="swift-empty-message">
4959No photo purchases yet.</p>'; 4960 // Update header count 4961 const h3 = section.querySelector('h3'); 4962 if (h3) h3.textContent = 'Purchased Photos (0)'; 4963 return; 4964 } 4965 4966 const licenses = result.licenses; 4967 const h3 = section.querySelector('h3'); 4968 if (h3) h3.textContent = 'Purchased Photos (' + licenses.length + ')'; 4969 4970 // Add license terms link to header 4971 const header = section.querySelector('.swift-section-header'); 4972 if (header) { 4973 const termsLink = document.createElement('a'); 4974 termsLink.href = termsUrl; 4975 termsLink.target = '_blank'; 4976 termsLink.rel = 'noopener noreferrer'; 4977 termsLink.className = 'swift-btn swift-btn-outline swift-btn-sm'; 4978 termsLink.textContent = 'License Terms'; 4979 header.appendChild(termsLink); 4980 } 4981 4982 content.innerHTML = '<div class="swift-photo-purchases-grid">' + 4983 licenses.map(function(license) { 4984 const caption = license.photoCaption || 'Photo'; 4985 const articleTitle = license.postTitle || ''; 4986 const photoUrl = license.photoUrl || ''; 4987 const date = license.createdAt ? new Date(license.createdAt).toLocaleDateString() : ''; 4988 const amount = license.amountCents ? String.fromCharCode(36) + (license.amountCents / 100).toFixed(2) : ''; 4989 4990 return '<div class="swift-photo-purchase-item">' + 4991 (photoUrl ? '<a href="' + photoUrl + '" target="_blank" rel="noopener noreferrer" class="swift-photo-purchase-thumb"><img src="' + photoUrl + '" alt="' + caption.replace(/"/g, '"') + '" loading="lazy"></a>' : '') + 4992 '<div class="swift-photo-purchase-info">' + 4993 '<div class="swift-photo-purchase-caption">' + caption + '</div>' + 4994 (articleTitle ? '<div class="swift-photo-purchase-article">From: ' + (license.postId ? '<a href="/?p=' + license.postId + '">' + articleTitle + '</a>' : articleTitle) + '</div>' : '') + 4995 '<div class="swift-photo-purchase-meta">' + (date ? '<span>' + date + '</span>' : '') + (amount ? '<span> · ' + amount + '</span>' : '') + '</div>' + 4996 '</div>' + 4997 (photoUrl ? '<a href="' + photoUrl + '" target="_blank" rel="noopener noreferrer" class="swift-btn swift-btn-primary swift-btn-sm">Download</a>' : '') + 4998 '</div>'; 4999 }).join('') + 5000 '</div>'; 5001 5002 } catch (err) { 5003 console.error('Failed to load photo purchases:', err); 5004 content.innerHTML = '<p class="swift-empty-message">Failed to load purchases.</p>'; 5005 } 5006 } 5007 5008 function attachProfileEventHandlers(container, profile) { 5009 // Store detected location data to include in profile save 5010 let detectedLocationData = null; 5011 // Save profile button 5012 const saveBtn = container.querySelector('#swift-profile-save'); 5013 const statusEl = container.querySelector('#swift-profile-save-status'); 5014 5015 if (saveBtn) { 5016 saveBtn.addEventListener('click', async () => { 5017 const givenNameInput = container.querySelector('#swift-profile-given-name'); 5018 const familyNameInput = container.querySelector('#swift-profile-family-name'); 5019 const postalInput = container.querySelector('#swift-profile-postal'); 5020 5021 const updatedData = { 5022 givenName: givenNameInput?.value || '', 5023 familyName: familyNameInput?.value || '', 5024 postalCode: postalInput?.value || '' 5025 }; 5026 5027 // Include location fields if geolocation was used 5028 if (detectedLocationData) { 5029 Object.assign(updatedData, { 5030 city: detectedLocationData.city, 5031 state: detectedLocationData.state, 5032 county: detectedLocationData.county, 5033 country: detectedLocationData.country, 5034 latitude: detectedLocationData.latitude, 5035 longitude: detectedLocationData.longitude, 5036 formattedAddress: detectedLocationData.formattedAddress, 5037 locationUpdatedAt: detectedLocationData.locationUpdatedAt 5038 }); 5039 } 5040 5041 saveBtn.disabled = true; 5042 saveBtn.textContent = 'Saving...'; 5043 if (statusEl) statusEl.textContent = ''; 5044 5045 try { 5046 // Use the updateProfile function (handles direct DynamoDB or AJAX) 5047 const response = await updateProfile(updatedData); 5048 5049 if (!response.success) { 5050 throw new Error(response.error || 'Failed to save'); 5051 } 5052 5053 // Also save to localStorage for backwards compatibility (if not migrated) 5054 if (!state.localStorageMigrated) { 5055 const updatedProfile = { ...profile, ...updatedData }; 5056 saveLocalStorageProfile(updatedProfile); 5057 } 5058 5059 saveBtn.textContent = 'Saved!'; 5060 saveBtn.style.background = '#28a745'; 5061 5062 setTimeout(() => { 5063 saveBtn.textContent = 'Save Changes'; 5064 saveBtn.style.background = ''; 5065 saveBtn.disabled = false;
5066 }, 2000); 5067 5068 } catch (err) { 5069 console.error('SwiftAuth: Profile save failed', err); 5070 saveBtn.textContent = 'Save Changes'; 5071 saveBtn.disabled = false; 5072 if (statusEl) { 5073 statusEl.textContent = 'Failed to save'; 5074 statusEl.style.color = '#dc3545'; 5075 } 5076 } 5077 }); 5078 } 5079 5080 // Locate postal code button 5081 const locateBtn = container.querySelector('#swift-locate-postal'); 5082 if (locateBtn) { 5083 locateBtn.addEventListener('click', async () => { 5084 const postalInput = container.querySelector('#swift-profile-postal'); 5085 const originalHtml = locateBtn.innerHTML; 5086 5087 // Show spinner 5088 locateBtn.disabled = true; 5089 locateBtn.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>'; 5090 locateBtn.classList.add('swift-postal-locating'); 5091 5092 try { 5093 const locationData = await detectPostalCode(); 5094 if (locationData.postalCode && postalInput) { 5095 postalInput.value = locationData.postalCode; 5096 } 5097 // Store for inclusion in save 5098 detectedLocationData = locationData; 5099 } catch (err) { 5100 console.error('SwiftAuth: Geolocation failed', err); 5101 const msg = getGeoErrorMessage(err); 5102 const statusEl = container.querySelector('#swift-profile-save-status'); 5103 if (statusEl) { 5104 statusEl.textContent = msg; 5105 statusEl.style.color = '#dc3545'; 5106 setTimeout(() => { statusEl.textContent = ''; }, 5000); 5107 } 5108 } finally { 5109 locateBtn.disabled = false; 5110 locateBtn.innerHTML = originalHtml; 5111 locateBtn.classList.remove('swift-postal-locating'); 5112 } 5113 }); 5114 } 5115 5116 // Change Password toggle, cancel, and submit 5117 const cpToggle = container.querySelector('#swift-change-password-toggle'); 5118 const cpWrapper = container.querySelector('#swift-change-password-form-wrapper'); 5119 const cpCancel = container.querySelector('#swift-change-password-cancel'); 5120 const cpSubmit = container.querySelector('#swift-change-password-submit'); 5121 const cpStatus = container.querySelector('#swift-change-password-status'); 5122 5123 const eyeSvg = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'; 5124 const eyeOffSvg = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>'; 5125 5126 const resetPasswordForm = () => { 5127 if (cpWrapper) cpWrapper.style.display = 'none'; 5128 const fields = ['#swift-current-password', '#swift-new-password', '#swift-confirm-password']; 5129 fields.forEach(sel => { const el = container.querySelector(sel); if (el) { el.value = ''; el.type = 'password'; } }); 5130 // Reset all toggle buttons back to eye icon 5131 if (cpWrapper) { 5132 cpWrapper.querySelectorAll('.swift-password-toggle').forEach(btn => { btn.innerHTML = eyeSvg; }); 5133 } 5134 if (cpStatus) { cpStatus.textContent = ''; cpStatus.style.color = ''; } 5135 if (cpSubmit) { cpSubmit.disabled = false; cpSubmit.textContent = 'Update Password'; } 5136 }; 5137 5138 if (cpToggle && cpWrapper) { 5139 cpToggle.addEventListener('click', () => { 5140 const isHidden = cpWrapper.style.display === 'none'; 5141 cpWrapper.style.display = isHidden ? 'block' : 'none';
5142 if (isHidden) { 5143 const firstInput = container.querySelector('#swift-current-password'); 5144 if (firstInput) firstInput.focus(); 5145 } 5146 }); 5147 } 5148 5149 if (cpCancel) { 5150 cpCancel.addEventListener('click', resetPasswordForm); 5151 } 5152 5153 // Password visibility toggles 5154 if (cpWrapper) { 5155 cpWrapper.querySelectorAll('.swift-password-toggle').forEach(btn => { 5156 btn.addEventListener('click', () => { 5157 const input = btn.parentElement.querySelector('input'); 5158 if (!input) return; 5159 const isPassword = input.type === 'password'; 5160 input.type = isPassword ? 'text' : 'password'; 5161 btn.innerHTML = isPassword ? eyeOffSvg : eyeSvg; 5162 }); 5163 }); 5164 } 5165 5166 if (cpSubmit) { 5167 cpSubmit.addEventListener('click', async () => { 5168 const currentPw = container.querySelector('#swift-current-password')?.value || ''; 5169 const newPw = container.querySelector('#swift-new-password')?.value || ''; 5170 const confirmPw = container.querySelector('#swift-confirm-password')?.value || ''; 5171 5172 if (cpStatus) { cpStatus.textContent = ''; cpStatus.style.color = ''; } 5173 5174 if (!currentPw || !newPw || !confirmPw) { 5175 if (cpStatus) { cpStatus.textContent = 'All fields are required.'; cpStatus.style.color = '#dc3545'; } 5176 return; 5177 } 5178 if (newPw.length < 8) { 5179 if (cpStatus) { cpStatus.textContent = 'New password must be at least 8 characters.'; cpStatus.style.color = '#dc3545'; } 5180 return; 5181 } 5182 if (newPw !== confirmPw) { 5183 if (cpStatus) { cpStatus.textContent = 'New passwords do not match.'; cpStatus.style.color = '#dc3545'; } 5184 return; 5185 } 5186 5187 cpSubmit.disabled = true; 5188 cpSubmit.textContent = 'Updating...'; 5189 5190 try { 5191 await changePassword(currentPw, newPw); 5192 if (cpStatus) { cpStatus.textContent = 'Password changed successfully!'; cpStatus.style.color = '#28a745'; } 5193 setTimeout(resetPasswordForm, 2000); 5194 } catch (err) { 5195 console.error('SwiftAuth: Change password failed', err); 5196 const msg = err.message || 'Failed to change password.'; 5197 if (cpStatus) { cpStatus.textContent = msg; cpStatus.style.color = '#dc3545'; } 5198 cpSubmit.disabled = false; 5199 cpSubmit.textContent = 'Update Password'; 5200 } 5201 }); 5202 } 5203 5204 // Manage Subscription button (Stripe portal) 5205 const manageSubBtn = container.querySelector('.swift-manage-sub-btn'); 5206 if (manageSubBtn) { 5207 manageSubBtn.addEventListener('click', async (e) => { 5208 e.preventDefault(); 5209 manageSubBtn.classList.add('swift-btn-loading'); 5210 manageSubBtn.textContent = 'Opening...'; 5211 try { 5212 const result = await ajaxRequest('swift_checkout_portal', { 5213 return_url: window.location.href, 5214 }); 5215 if (result.success && result.portal_url) { 5216 window.location.href = result.portal_url; 5217 } else { 5218 manageSubBtn.textContent = 'Manage Subscription'; 5219 manageSubBtn.classList.remove('swift-btn-loading'); 5220 alert(result.error || 'Could not open subscription portal.'); 5221 } 5222 } catch (err) { 5223 manageSubBtn.textContent = 'Manage Subscription'; 5224 manageSubBtn.classList.remove('swift-btn-loading'); 5225 alert('Could not open subscription portal.'); 5226 } 5227 }); 5228 } 5229 5230 // Profile Subscribe Now button â track conversion source 5231 const profileSubBtn = container.querySelector('.swift-profile-subscribe-btn'); 5232 if (profileSubBtn) { 5233 profileSubBtn.addEventListener('click', () => { 5234 setConversionSource('profile', window.location.href); 5235 }); 5236 } 5237 5238 // Download Receipts button (Stripe portal) 5239 const receiptsBtn = container.querySelector('.swift-download-receipts-btn'); 5240 if (receiptsBtn) { 5241 receiptsBtn.addEventListener('click', async (e) => { 5242 e.preventDefault(); 5243 receiptsBtn.classList.add('swift-btn-loading'); 5244 receiptsBtn.textContent = 'Opening...'; 5245 try { 5246 const result = await ajaxRequest('swift_checkout_portal', { 5247 return_url: window.location.href, 5248 }); 5249 if (result.success && result.portal_url) { 5250 window.location.href = result.portal_url; 5251 } else { 5252 receiptsBtn.textContent = 'Download Receipts'; 5253 receiptsBtn.classList.remove('swift-btn-loading'); 5254 alert(result.error || 'Could not open billing portal.'); 5255 } 5256 } catch (err) { 5257 receiptsBtn.textContent = 'Download Receipts'; 5258 receiptsBtn.classList.remove('swift-btn-loading');
5259 alert('Could not open billing portal.'); 5260 } 5261 }); 5262 } 5263 5264 // Remove bookmark handlers 5265 container.querySelectorAll('[data-swift-remove-bookmark]').forEach(btn => { 5266 btn.addEventListener('click', async (e) => { 5267 e.preventDefault(); 5268 const url = btn.getAttribute('data-swift-remove-bookmark'); 5269 // Use postId if available - this is the key used in DynamoDB 5270 const postId = btn.getAttribute('data-post-id'); 5271 const listItem = btn.closest('.swift-list-item'); 5272 5273 if (listItem) { 5274 listItem.style.opacity = '0.5'; 5275 } 5276 5277 try { 5278 // Remove from DynamoDB via API - prefer postId as it matches the DynamoDB key 5279 await removeBookmark(postId || url); 5280 5281 // Only remove from localStorage if not yet migrated 5282 if (!state.localStorageMigrated) { 5283 const pubID = config.pubID || ''; 5284 const key = pubID + 'bookmarkedPosts'; 5285 const stored = localStorage.getItem(key); 5286 if (stored) { 5287 const bookmarks = JSON.parse(stored); 5288 const filtered = bookmarks.filter(b => { 5289 const bUrl = b.articleUrl || b.bookmarkShortlink || b.url || ''; 5290 return bUrl !== url; 5291 }); 5292 localStorage.setItem(key, JSON.stringify(filtered)); 5293 } 5294 } 5295 5296 initProfilePage(); 5297 } catch (err) { 5298 console.error('SwiftAuth: Remove bookmark failed', err); 5299 if (listItem) { 5300 listItem.style.opacity = '1'; 5301 } 5302 } 5303 }); 5304 }); 5305 5306 // Unfollow topic handlers 5307 container.querySelectorAll('[data-swift-unfollow-topic]').forEach(btn => { 5308 btn.addEventListener('click', async (e) => { 5309 e.preventDefault(); 5310 const topicId = btn.getAttribute('data-swift-unfollow-topic'); 5311 const tagEl = btn.closest('.swift-topic-tag'); 5312 5313 if (tagEl) { 5314 tagEl.style.opacity = '0.5'; 5315 } 5316 5317 try { 5318 // Use the unfollowTopic function (handles direct DynamoDB or AJAX) 5319 const result = await unfollowTopic(topicId); 5320 5321 if (!result.success) { 5322 throw new Error(result.error || 'Failed to unfollow'); 5323 } 5324 5325 initProfilePage(); 5326 } catch (err) { 5327 console.error('SwiftAuth: Unfollow topic failed', err); 5328 if (tagEl) { 5329 tagEl.style.opacity = '1'; 5330 } 5331 } 5332 }); 5333 }); 5334 5335 // Reading history toggle 5336 const historyToggle = container.querySelector('#swift-history-toggle'); 5337 if (historyToggle) { 5338 historyToggle.addEventListener('change', (e) => { 5339 setReadingHistoryEnabled(e.target.checked); 5340 console.log('SwiftAuth: Reading history tracking', e.target.checked ? 'enabled' : 'disabled'); 5341 }); 5342 } 5343 5344 // Clear all history button 5345 const clearHistoryBtn = container.querySelector('#swift-clear-history'); 5346 if (clearHistoryBtn) { 5347 clearHistoryBtn.addEventListener('click', async () => { 5348 if (confirm('Are you sure you want to clear all reading history? This cannot be undone.')) { 5349 await clearReadingHistory(); 5350 initProfilePage(); 5351 } 5352 }); 5353 } 5354 5355 // Remove individual history items 5356 container.querySelectorAll('[data-swift-remove-history]').forEach(btn => { 5357 btn.addEventListener('click', async (e) => { 5358 e.preventDefault(); 5359 const url = btn.getAttribute('data-swift-remove-history'); 5360 const articleId = btn.getAttribute('data-article-id'); 5361 const listItem = btn.closest('.swift-list-item'); 5362 5363 if (listItem) { 5364 listItem.style.opacity = '0.5'; 5365 listItem.style.transition = 'opacity 0.2s'; 5366 } 5367 5368 try { 5369 await removeReadingHistoryItem(url, articleId); 5370 5371 // Animate out and re-render 5372 setTimeout(() => { 5373 initProfilePage(); 5374 }, 200); 5375 } catch (err) { 5376 console.error('SwiftAuth: Remove history item failed', err); 5377 if (listItem) { 5378 listItem.style.opacity = '1'; 5379 } 5380 } 5381 }); 5382 }); 5383 5384 // Sign out button 5385 const signOutBtn = container.querySelector('#swift-sign-out'); 5386 if (signOutBtn) { 5387 signOutBtn.addEventListener('click', async () => { 5388 signOutBtn.disabled = true; 5389 signOutBtn.textContent = 'Signing out...'; 5390 try { 5391 await logout(); 5392 window.location.href = '/'; 5393 } catch (err) { 5394 console.error('SwiftAuth: Sign out failed', err); 5395 signOutBtn.disabled = false;
5396 signOutBtn.textContent = 'Sign Out'; 5397 } 5398 }); 5399 } 5400 5401 // Delete account button 5402 const deleteAccountBtn = container.querySelector('#swift-delete-account'); 5403 if (deleteAccountBtn) { 5404 deleteAccountBtn.addEventListener('click', async () => { 5405 await deleteAccount(); 5406 }); 5407 } 5408 } 5409 5410 // ========================================================================= 5411 // UI Updates 5412 // ========================================================================= 5413 5414 /** 5415 * Update UI based on auth state 5416 */ 5417 function updateUI() { 5418 syncLoggedInSignal(); 5419 5420 const authLevel = state.authLevel; 5421 const isAuthenticated = authLevel === 'full' || authLevel === 'lite'; 5422 5423 // Update auth root 5424 const authRoots = document.querySelectorAll('[data-auth-root]'); 5425 authRoots.forEach(root => { 5426 root.setAttribute('data-auth-level', authLevel); 5427 }); 5428 5429 // Update auth nav elements (show/hide based on auth state) 5430 const guestNavEls = document.querySelectorAll('[data-swift-auth-nav="guest"]'); 5431 const userNavEls = document.querySelectorAll('[data-swift-auth-nav="user"]'); 5432 5433 guestNavEls.forEach(el => { 5434 el.classList.toggle('d-none', isAuthenticated); 5435 }); 5436 5437 userNavEls.forEach(el => { 5438 el.classList.toggle('d-none', !isAuthenticated); 5439 }); 5440 5441 // Update login buttons 5442 const loginButtons = document.querySelectorAll('[data-swift-login]'); 5443 loginButtons.forEach(btn => { 5444 if (authLevel === 'full') { 5445 btn.textContent = btn.getAttribute('data-logged-in-text') || 'Account'; 5446 btn.setAttribute('href', btn.getAttribute('data-profile-url') || '/profile/'); 5447 } else { 5448 btn.textContent = btn.getAttribute('data-logged-out-text') || 'Sign In'; 5449 btn.setAttribute('href', btn.getAttribute('data-login-url') || '/authentication/'); 5450 } 5451 }); 5452 5453 // Update bookmark buttons 5454 const bookmarkButtons = document.querySelectorAll('[data-swift-bookmark]'); 5455 bookmarkButtons.forEach(btn => { 5456 // Find parent container (e.g., .toggle-bookmark) that may be hidden 5457 const container = btn.closest('.toggle-bookmark') || btn; 5458 5459 if (authLevel !== 'full') { 5460 btn.classList.add('swift-bookmark-disabled'); 5461 btn.classList.remove('swift-bookmark-active'); 5462 btn.setAttribute('title', 'Sign in to bookmark'); 5463 // Show button for anonymous users (they can click to go to login) 5464 container.style.display = ''; 5465 } else { 5466 btn.classList.remove('swift-bookmark-disabled'); 5467 btn.removeAttribute('title'); 5468 // Prefer post ID over URL for stability 5469 const postId = btn.getAttribute('data-post-id'); 5470 const articleUrl = btn.getAttribute('data-article-url'); 5471 const identifier = postId || articleUrl; 5472 // Check bookmark state and then show the button 5473 checkBookmark(identifier).then(response => { 5474 btn.classList.toggle('swift-bookmark-active', response.bookmarked); 5475 // Show the container after we know the state 5476 container.style.display = ''; 5477 }); 5478 } 5479 }); 5480 5481 // Unlock content and clear walls if authenticated 5482 if (authLevel !== 'anonymous') { 5483 unlockContent(); 5484 hideRegwall(); 5485 // Also hide paywall container 5486 const paywallContainer = document.getElementById('swift-paywall-container'); 5487 if (paywallContainer) { 5488 paywallContainer.style.display = 'none'; 5489 } 5490 } 5491 5492 } 5493 5494 /** 5495 * Update bookmark button states from DynamoDB 5496 * Called after UserDataManager is initialized 5497 */ 5498 async function updateBookmarkButtonStates() { 5499 const bookmarkButtons = document.querySelectorAll('[data-swift-bookmark]'); 5500 if (bookmarkButtons.length === 0) return; 5501 5502 console.log('SwiftAuth: Updating bookmark button states from DynamoDB'); 5503 5504 for (const btn of bookmarkButtons) { 5505 const postId = btn.getAttribute('data-post-id'); 5506 const articleUrl = btn.getAttribute('data-article-url'); 5507 const identifier = postId || articleUrl; 5508 5509 if (!identifier) continue; 5510 5511 try { 5512 const response = await checkBookmark(identifier); 5513 console.log('SwiftAuth: Bookmark check for', identifier, '=', response.bookmarked); 5514 btn.classList.toggle('swift-bookmark-active', response.bookmarked); 5515 5516 // Show the container after we know the state 5517 const container = btn.closest('.toggle-bookmark') || btn; 5518 container.style.display = ''; 5519 } catch (err) { 5520 console.warn('SwiftAuth: Could not check bookmark state for', identifier, err); 5521 } 5522 } 5523 } 5524 5525 /** 5526 * Initialize bookmark button handlers 5527 */ 5528 function initBookmarkButtons() { 5529 document.querySelectorAll('[data-swift-bookmark]').forEach(btn => { 5530 btn.addEventListener('click', async (e) => { 5531 e.preventDefault(); 5532 5533 if (state.authLevel !== 'full') { 5534 // Store return URL and redirect to login 5535 setReturnUrl(window.location.href); 5536 window.location.href = '/authentication/'; 5537 return; 5538 } 5539 5540 // Prefer post ID over URL for stability (permalinks can change) 5541 const postId = btn.getAttribute('data-post-id'); 5542 const articleUrl = btn.getAttribute('data-article-url') || getArticleUrl(); 5543 const identifier = postId || articleUrl; 5544 5545 console.log('SwiftAuth: Bookmark button clicked', { postId, articleUrl, identifier }); 5546 5547 try { 5548 const response = await toggleBookmark(identifier); 5549 console.log('SwiftAuth: toggleBookmark result:', response); 5550 btn.classList.toggle('swift-bookmark-active', response.bookmarked); 5551 } catch (err) { 5552 console.error('SwiftAuth: Bookmark failed', err); 5553 } 5554 }); 5555 }); 5556 } 5557 5558 /** 5559 * Initialize login button handlers 5560 */ 5561 /** 5562 * Store return URL for post-login redirect 5563 * Uses sessionStorage so it only persists for the browser session 5564 */ 5565 function setReturnUrl(url) { 5566 if (!url) return; 5567 try { 5568 sessionStorage.setItem('swift_auth_return_url', url); 5569 console.log('SwiftAuth: Stored return URL:', url); 5570 } catch (err) { 5571 console.warn('SwiftAuth: Could not store return URL', err); 5572 } 5573 } 5574 5575 /** 5576 * Store conversion attribution for subscribe flow 5577 * Tracks which button/wall led the user to subscribe 5578 */ 5579 function setConversionSource(source, url) { 5580 try { 5581 sessionStorage.setItem('swift_conversion_source', source || 'direct'); 5582 sessionStorage.setItem('swift_conversion_url', url || window.location.href); 5583 } catch (err) { /* silent */ } 5584 if (window.SwiftAnalytics) { 5585 window.SwiftAnalytics.trackBeginCheckout(source, url); 5586 } 5587 } 5588 5589 function getConversionSource() { 5590 try { 5591 return { 5592 source: sessionStorage.getItem('swift_conversion_source') || 'direct', 5593 url: sessionStorage.getItem('swift_conversion_url') || '' 5594 }; 5595 } catch (err) { 5596 return { source: 'direct', url: '' }; 5597 } 5598 } 5599 5600 /** 5601 * Get and clear the stored return URL 5602 * Returns null if no URL is stored 5603 */ 5604 function getReturnUrl() { 5605 try { 5606 const url = sessionStorage.getItem('swift_auth_return_url'); 5607 if (url) { 5608 sessionStorage.removeItem('swift_auth_return_url'); 5609 console.log('SwiftAuth: Retrieved return URL:', url); 5610 } 5611 return url; 5612 } catch (err) { 5613 console.warn('SwiftAuth: Could not retrieve return URL', err); 5614 return null; 5615 } 5616 } 5617 5618 /** 5619 * Clear the stored return URL without returning it 5620 */ 5621 function clearReturnUrl() { 5622 try { 5623 sessionStorage.removeItem('swift_auth_return_url'); 5624 } catch (err) { 5625 // Ignore 5626 } 5627 } 5628 5629 function initLoginButtons() { 5630 document.querySelectorAll('[data-swift-login]:not(.swift-login-init)').forEach(btn => { 5631 btn.classList.add('swift-login-init'); 5632 btn.addEventListener('click', (e) => { 5633 if (state.authLevel === 'full') { 5634 // Let it navigate to profile 5635 return; 5636 } 5637 5638 // Prevent default and show login modal or redirect 5639 e.preventDefault(); 5640 5641 // Determine promotion_id from data-swift-wall-action or default to login 5642 const action = btn.getAttribute('data-swift-wall-action') || 'login'; 5643 5644 // Record wall CTA click if this button is inside a wall 5645 const regwallContainer = btn.closest('.swift-regwall-modal, .swift-regwall-inline'); 5646 if (regwallContainer) { 5647 const wallType = regwallContainer.getAttribute('data-wall-type') || 'soft'; 5648 const wallName = regwallContainer.getAttribute('data-wall-name') || ''; 5649 if (state.userDataManager && state.authLevel !== 'full') { 5650 state.userDataManager.recordWallEvent('regwall', wallType, 'cta_clicked'); 5651 } 5652 if (window.SwiftAnalytics) { 5653 const actionSuffix = action === 'register' ? 'ClickReg' : 'ClickLogin'; 5654 window.SwiftAnalytics.trackWallSelectPromotion(wallType, actionSuffix, wallName, !!config.paywall?.enabled); 5655 } 5656 } 5657 const paywallContainer = btn.closest('.swift-paywall-modal, .swift-paywall-inline-hard, .swift-paywall-inline-soft'); 5658 if (paywallContainer) { 5659 const wallType = paywallContainer.getAttribute('data-wall-type') || 'hard'; 5660 const wallName = paywallContainer.getAttribute('data-wall-name') || ''; 5661 if (state.userDataManager && state.authLevel !== 'full') { 5662 state.userDataManager.recordWallEvent('paywall', wallType, 'cta_clicked'); 5663 } 5664 if (window.SwiftAnalytics) { 5665 window.SwiftAnalytics.trackWallSelectPromotion(wallType, 'ClickLogin', wallName, !!config.paywall?.enabled); 5666 } 5667 } 5668 5669 // Store current URL if on an article page (has regwall/paywall content) 5670 const article = document.querySelector('.p402_premium'); 5671 if (article) { 5672 setReturnUrl(window.location.href); 5673 } 5674 5675 window.location.href = '/authentication/'; 5676 }); 5677 }); 5678 5679 // "Why register/subscribe" links inside walls 5680 // These are standard <a> links â we just fire the GA event before navigation 5681 document.querySelectorAll('[data-swift-wall-action="why"]:not(.swift-why-init)').forEach(link => { 5682 link.classList.add('swift-why-init'); 5683 link.addEventListener('click', () => { 5684 if (window.SwiftAnalytics) { 5685 const wallContainer = link.closest('.swift-regwall-modal, .swift-regwall-inline, .swift-paywall-modal, .swift-paywall-inline-hard, .swift-paywall-inline-soft'); 5686 if (wallContainer) { 5687 const wallType = wallContainer.getAttribute('data-wall-type') || 'soft'; 5688 const wallName = wallContainer.getAttribute('data-wall-name') || ''; 5689 window.SwiftAnalytics.trackWallSelectPromotion(wallType, 'ClickMore', wallName, !!config.paywall?.enabled); 5690 } 5691 } 5692 // Don't preventDefault â let the link navigate naturally 5693 }); 5694 }); 5695 } 5696 5697 /** 5698 * Check if a topic is followed 5699 */ 5700 function isTopicFollowed(topicId) { 5701 return state.followedTopics.some(t => { 5702 const tId = (typeof t === 'object') ? (t.topicId || t.id) : t; 5703 return tId === topicId; 5704 }); 5705 } 5706 5707 /** 5708 * Follow a topic 5709 * Uses direct DynamoDB when UserDataManager is ready 5710 */
5711 async function followTopic(topicId, topicName) { 5712 if (!topicId) { 5713 console.warn('SwiftAuth: Cannot follow topic without ID'); 5714 return { success: false, error: 'Missing topic ID' }; 5715 } 5716 5717 // Check if already followed 5718 if (isTopicFollowed(topicId)) { 5719 return { success: true, alreadyFollowed: true }; 5720 } 5721 5722 const newTopic = { topicId, topicName: topicName || topicId }; 5723 5724 // Direct DynamoDB only - no AJAX fallback for user data 5725 if (!isDirectDynamoEnabled()) { 5726 console.error('SwiftAuth: DynamoDB not initialized - cannot follow topic'); 5727 return { success: false, error: 'DynamoDB not initialized' }; 5728 } 5729 5730 try { 5731 const result = await state.userDataManager.followTopic(newTopic); 5732 state.followedTopics = [...state.followedTopics, result || newTopic]; 5733 window.dispatchEvent(new CustomEvent('swiftauth:topics_changed', { 5734 detail: { topics: state.followedTopics } 5735 })); 5736 return { success: true, followed: true }; 5737 } catch (error) {
5738 console.error('SwiftAuth: followTopic failed:', error); 5739 return { success: false, error: error.message }; 5740 } 5741 } 5742 5743 /** 5744 * Unfollow a topic 5745 * Uses direct DynamoDB when UserDataManager is ready 5746 */ 5747 async function unfollowTopic(topicId) { 5748 if (!topicId) { 5749 return { success: false, error: 'Missing topic ID' }; 5750 } 5751 5752 // Direct DynamoDB only - no AJAX fallback for user data 5753 if (!isDirectDynamoEnabled()) { 5754 console.error('SwiftAuth: DynamoDB not initialized - cannot unfollow topic'); 5755 return { success: false, error: 'DynamoDB not initialized' }; 5756 } 5757 5758 try { 5759 await state.userDataManager.unfollowTopic(topicId); 5760 state.followedTopics = state.followedTopics.filter(t => { 5761 const tId = (typeof t === 'object') ? (t.topicId || t.id) : t; 5762 return tId !== topicId; 5763 }); 5764 window.dispatchEvent(new CustomEvent('swiftauth:topics_changed', { 5765 detail: { topics: state.followedTopics } 5766 })); 5767 return { success: true, unfollowed: true }; 5768 } catch (error) { 5769 console.error('SwiftAuth: unfollowTopic failed:', error); 5770 return { success: false, error: error.message }; 5771 } 5772 } 5773 5774 /** 5775 * Toggle follow state for a topic 5776 */ 5777 async function toggleFollowTopic(topicId, topicName) { 5778 if (isTopicFollowed(topicId)) { 5779 return unfollowTopic(topicId); 5780 } else { 5781 return followTopic(topicId, topicName); 5782 } 5783 } 5784 5785 /** 5786 * Update follow topic button UI 5787 * Theme convention: 5788 * - No 'follow-topic' class = "Following" state (solid blue, checkmark) 5789 * - With 'follow-topic' class = "Follow" state (outlined, plus icon) 5790 */ 5791 function updateFollowTopicButton(btn, isFollowed) { 5792 // Theme class: add 'follow-topic' when NOT following, remove when following 5793 btn.classList.toggle('follow-topic', !isFollowed); 5794 // Plugin class for any custom styling 5795 btn.classList.toggle('swift-topic-following', isFollowed); 5796 btn.setAttribute('data-following', isFollowed ? 'true' : 'false'); 5797 5798 // Update button text if it has a text element (theme handles icons via CSS) 5799 const textEl = btn.querySelector('[data-follow-text]'); 5800 if (textEl) { 5801 const topicName = btn.getAttribute('data-topic-name') || btn.getAttribute('data-topic'); 5802 textEl.textContent = isFollowed ? `Following ${topicName}` : `Follow ${topicName}`; 5803 } 5804 } 5805 5806 /** 5807 * Initialize follow topic button handlers 5808 */ 5809 function initFollowTopicButtons() { 5810 const buttons = document.querySelectorAll('[data-topic]'); 5811 const isArticlePage = document.querySelector('.p402_premium') !== null; 5812 const isProfilePage = document.querySelector('#swift-profile-root') !== null; 5813 5814 buttons.forEach(btn => { 5815 const topicId = btn.getAttribute('data-topic'); 5816 const topicName = btn.getAttribute('data-topic-name') || topicId; 5817 5818 // Set initial state 5819 const isFollowed = isTopicFollowed(topicId); 5820 updateFollowTopicButton(btn, isFollowed); 5821 5822 btn.addEventListener('click', async (e) => { 5823 e.preventDefault(); 5824 5825 if (state.authLevel !== 'full') { 5826 // Redirect to login 5827 window.location.href = '/authentication/'; 5828 return; 5829 } 5830 5831 // Disable button during request 5832 btn.disabled = true; 5833 const wasFollowed = isTopicFollowed(topicId); 5834 5835 try { 5836 const result = await toggleFollowTopic(topicId, topicName); 5837 5838 if (result.success) { 5839 updateFollowTopicButton(btn, !wasFollowed); 5840 } 5841 } catch (err) { 5842 console.error('SwiftAuth: Toggle follow topic failed', err); 5843 } finally { 5844 btn.disabled = false;
5845 } 5846 }); 5847 }); 5848 5849 // Reveal topic buttons on article pages after state has been set 5850 // (Theme hides them with [data-topic] { display: none; } until data is loaded) 5851 if (isArticlePage && !isProfilePage) { 5852 buttons.forEach(btn => { 5853 btn.style.display = 'inline-flex'; 5854 }); 5855 } 5856 } 5857 5858 /** 5859 * Initialize follow buttons on staff profile pages 5860 * Detects [data-swift-follow-author] containers and renders follow buttons 5861 */ 5862 function initAuthorFollowContainers() { 5863 const containers = document.querySelectorAll('[data-swift-follow-author]'); 5864 if (!containers.length) return; 5865 5866 // Derive author slug from URL: /profile/robert-tann/ â author-robert-tann 5867 const pathParts = window.location.pathname.replace(/\/$/, '').split('/'); 5868 const slug = pathParts[pathParts.length - 1]; 5869 const topicId = 'author-' + slug; 5870 5871 containers.forEach(container => { 5872 const rawName = container.getAttribute('data-swift-follow-author-name') || slug; 5873 5874 // Build and inject the button 5875 const btn = document.createElement('button'); 5876 btn.setAttribute('data-topic', topicId); 5877 btn.setAttribute('data-topic-name', rawName); 5878 btn.setAttribute('type', 'button'); 5879 btn.innerHTML = `<span data-follow-text></span>`; 5880 container.appendChild(btn); 5881 5882 // Set initial state using shared utility 5883 updateFollowTopicButton(btn, isTopicFollowed(topicId)); 5884 5885 btn.addEventListener('click', async (e) => { 5886 e.preventDefault(); 5887 if (state.authLevel !== 'full') { 5888 window.location.href = '/authentication/'; 5889 return; 5890 } 5891 btn.disabled = true; 5892 const wasFollowed = isTopicFollowed(topicId); 5893 try { 5894 const result = await toggleFollowTopic(topicId, rawName); 5895 if (result.success) { 5896 updateFollowTopicButton(btn, !wasFollowed); 5897 } 5898 } catch (err) { 5899 console.error('SwiftAuth: Author follow failed', err); 5900 } finally { 5901 btn.disabled = false; 5902 } 5903 }); 5904 }); 5905 } 5906 5907 // ========================================================================= 5908 // Social Sign-In (Google & Facebook) 5909 // ========================================================================= 5910 5911 let googleClient = null; 5912 5913 /** 5914 * Initialize Google Identity Services 5915 * Call this on page load for the authentication page 5916 */ 5917 function initGoogleSignIn() { 5918 if (!config.social?.googleEnabled || !config.social?.googleClientId) { 5919 console.log('SwiftAuth: Google Sign-In not configured'); 5920 return; 5921 } 5922 5923 if (typeof google === 'undefined') { 5924 console.log('SwiftAuth: Google Identity Services SDK not loaded yet'); 5925 return; 5926 } 5927 5928 // Initialize the Google Sign-In client 5929 googleClient = google.accounts.oauth2.initTokenClient({ 5930 client_id: config.social.googleClientId, 5931 scope: 'email profile openid', 5932 callback: handleGoogleResponse, 5933 }); 5934 5935 console.log('SwiftAuth: Google Sign-In initialized'); 5936 } 5937 5938 /** 5939 * Handle Google Sign-In button click 5940 * Opens Google popup for authentication 5941 */ 5942 function signInWithGoogle() { 5943 console.log('SwiftAuth: signInWithGoogle() called'); 5944 console.log('SwiftAuth: googleClient:', googleClient); 5945 console.log('SwiftAuth: config.social:', config.social); 5946 5947 if (!googleClient) { 5948 console.log('SwiftAuth: Google client not initialized, falling back to OAuth redirect'); 5949 // Fallback to direct Cognito OAuth redirect 5950 initiateOAuthSignIn('Google'); 5951 return; 5952 } 5953 5954 console.log('SwiftAuth: Requesting Google access token via popup...'); 5955 // Request access token via popup 5956 googleClient.requestAccessToken(); 5957 } 5958 5959 /** 5960 * Handle response from Google popup 5961 * After successful Google auth, redirect to Cognito OAuth 5962 */ 5963 function handleGoogleResponse(response) { 5964 if (response.error) { 5965 console.error('SwiftAuth: Google Sign-In error', response.error); 5966 showSocialAuthError('Google sign-in failed. Please try again.'); 5967 return; 5968 } 5969 5970 console.log('SwiftAuth: Google authentication successful, redirecting to Cognito...'); 5971 5972 // Google session is now established in browser 5973 // Redirect to Cognito OAuth - Google will recognize session (instant) 5974 initiateOAuthSignIn('Google'); 5975 } 5976 5977 /** 5978 * Handle Facebook Sign-In button click 5979 * Redirects directly to Cognito OAuth which handles the full Facebook flow 5980 */ 5981 function signInWithFacebook() { 5982 console.log('SwiftAuth: signInWithFacebook() called'); 5983 initiateOAuthSignIn('Facebook'); 5984 } 5985 5986 /** 5987 * Show error message for social auth failures 5988 */ 5989 function showSocialAuthError(message) { 5990 // Dispatch event for theme to handle 5991 document.dispatchEvent(new CustomEvent('swiftauth:social_error', { 5992 detail: { message } 5993 })); 5994
5995 // Also try to show in any visible error container 5996 const errorContainers = document.querySelectorAll('[data-swift-auth-error], .swift-auth-error'); 5997 errorContainers.forEach(container => { 5998 container.textContent = message; 5999 container.style.display = 'block'; 6000 }); 6001 } 6002 6003 /** 6004 * Redirect to Cognito OAuth endpoint with identity_provider parameter 6005 * Called AFTER provider popup authentication succeeds 6006 * Provider will recognize the session, making this redirect instant 6007 */ 6008 function initiateOAuthSignIn(provider) { 6009 console.log('SwiftAuth: initiateOAuthSignIn() called with provider:', provider); 6010 6011 let domain = config.cognito?.domain; 6012 const clientId = config.cognito?.clientId; 6013 6014 // Strip https:// or http:// prefix if present (user may have entered full URL) 6015 if (domain) { 6016 domain = domain.replace(/^https?:\/\//, ''); 6017 } 6018 6019 console.log('SwiftAuth: Cognito domain:', domain); 6020 console.log('SwiftAuth: Cognito clientId:', clientId); 6021 6022 if (!domain) { 6023 console.error('SwiftAuth: Cognito domain not configured!'); 6024 console.error('SwiftAuth: Make sure to set the Cognito Domain in WordPress admin: Settings > Swift Auth > Authentication'); 6025 showSocialAuthError('Social sign-in is not configured properly. Please contact the site administrator.'); 6026 return; 6027 } 6028 6029 // Use current origin for redirect - supports multiple custom domains 6030 const redirectUri = encodeURIComponent(window.location.origin + '/authentication/'); 6031 6032 // Check if we already have a return URL stored (from clicking login on article page) 6033 // If not, store current page (unless it's /authentication/) 6034 const existingReturnUrl = sessionStorage.getItem('swift_auth_return_url'); 6035 if (!existingReturnUrl && !window.location.pathname.includes('/authentication')) { 6036 sessionStorage.setItem('swift_auth_return_url', window.location.href); 6037 } 6038 6039 const url = `https://${domain}/oauth2/authorize?` + 6040 `identity_provider=${provider}&` + 6041 `redirect_uri=${redirectUri}&` + 6042 `response_type=code&` + 6043 `client_id=${clientId}&` + 6044 `scope=aws.cognito.signin.user.admin+email+openid+profile`; 6045 6046 console.log(`SwiftAuth: Redirecting to Cognito OAuth URL:`, url); 6047 window.location.href = url; 6048 } 6049 6050 /** 6051 * Handle OAuth callback on /authentication/ page 6052 * Called when user returns from social provider with auth code 6053 */ 6054 async function handleOAuthCallback() { 6055 const urlParams = new URLSearchParams(window.location.search); 6056 const code = urlParams.get('code'); 6057 const error = urlParams.get('error'); 6058 const errorDescription = urlParams.get('error_description'); 6059 6060 // Hide auth page forms and show loading indicator 6061 const authPage = document.querySelector('[data-authentication]'); 6062 if (authPage) { 6063 authPage.innerHTML = ` 6064 <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;padding:60px 20px;text-align:center;"> 6065 <div class="swift-oauth-spinner"></div> 6066 <p style="margin-top:16px;font-size:1rem;color:#555;">Logging in...</p> 6067 </div> 6068 `; 6069 // Inject spinner styles if not present 6070 if (!document.getElementById('swift-oauth-spinner-style')) { 6071 const style = document.createElement('style'); 6072 style.id = 'swift-oauth-spinner-style'; 6073 style.textContent = ` 6074 .swift-oauth-spinner { 6075 width: 36px; height: 36px; 6076 border: 3px solid #e0e0e0; 6077 border-top-color: #0074d4; 6078 border-radius: 50%; 6079 animation: swift-oauth-spin 0.8s linear infinite; 6080 } 6081 @keyframes swift-oauth-spin { to { transform: rotate(360deg); } } 6082 `; 6083 document.head.appendChild(style); 6084 } 6085 } 6086 6087 // Handle OAuth errors 6088 if (error) { 6089 console.error('OAuth error:', error, errorDescription); 6090 window.location.href = '/authentication/?auth_error=' + encodeURIComponent(errorDescription || 'Social sign-in failed'); 6091 return; 6092 } 6093 6094 if (!code) { 6095 return; // No code, not an OAuth callback 6096 } 6097 6098 console.log('SwiftAuth: Processing OAuth callback'); 6099 6100 try {
6101 // Fetch a fresh nonce BEFORE any AJAX calls. ajaxRequest() also does 6102 // this lazily, but resolving it up front keeps the OAuth flow explicit. 6103 await fetchNonce(); 6104 6105 // Strip the authorization code from the URL immediately so it doesn't 6106 // leak via Referer headers, ad network scripts, or analytics. 6107 window.history.replaceState({}, '', '/authentication/'); 6108 6109 // Exchange authorization code for tokens 6110 const tokens = await exchangeCodeForTokens(code); 6111 6112 if (!tokens || !tokens.id_token) { 6113 throw new Error('Failed to exchange code for tokens'); 6114 } 6115 6116 // Store tokens in localStorage for Cognito JS SDK compatibility 6117 // This allows getCurrentUser() to work on subsequent page loads 6118 storeCognitoTokens(tokens); 6119 6120 // Complete auth flow with WordPress (same as email/password login) 6121 // This is the critical path - creates WordPress session cookie 6122 const response = await ajaxRequest('swift_auth_complete', { 6123 id_token: tokens.id_token, 6124 }); 6125 6126 if (response.success) { 6127 state.authLevel = 'full'; 6128 state.sub = response.sub; 6129 6130 console.log('SwiftAuth: Social sign-in complete'); 6131 6132 // Emit login event for authBridge to trigger hydration 6133 window.dispatchEvent(new CustomEvent('swiftauth:login')); 6134 6135 // Await profile sync before redirect (matching login() flow) 6136 // MUST be awaited so profile data exists when profile page loads 6137 try { 6138 const dmSuccess = await initUserDataManager(tokens.id_token); 6139 console.log('SwiftAuth: OAuth initUserDataManager result:', dmSuccess); 6140 if (dmSuccess) { 6141 await syncUserProfileFromToken(tokens.id_token); 6142 // Record login event for dashboard analytics (fire-and-forget) 6143 if (state.userDataManager) state.userDataManager.recordLogin(); 6144 } else { 6145 console.warn('SwiftAuth: UserDataManager init failed â profile will not sync to DynamoDB'); 6146 console.warn('SwiftAuth: initError:', state.userDataManager?.initError?.message); 6147 } 6148 } catch (e) { 6149 console.log('SwiftAuth: Profile sync failed (non-blocking)', e); 6150 } 6151 6152 // Pinpoint stays fire-and-forget (not needed before redirect) 6153 try { 6154 const tokenParts = tokens.id_token.split('.'); 6155 const payload = JSON.parse(atob(tokenParts[1])); 6156 if (payload.email) { 6157 updatePinpointEndpoint(response.sub, payload.email); 6158 } 6159 } catch (e) { 6160 console.log('SwiftAuth: Pinpoint update failed (non-blocking)', e); 6161 } 6162 6163 // Dispatch success event 6164 document.dispatchEvent(new CustomEvent('swiftauth:social_success', { 6165 detail: { sub: response.sub } 6166 })); 6167 6168 // Redirect back to original page (article) if stored, otherwise homepage 6169 // Check if user needs onboarding (hasn't completed it yet) 6170 if (state.userDataManager && state.userDataManagerReady) { 6171 try { 6172 const profile = await state.userDataManager.getProfile(); 6173 if (!profile.onboardingCompleted) { 6174 console.log('SwiftAuth: New user needs onboarding, redirecting...'); 6175 window.location.href = '/onboarding/'; 6176 return; 6177 } 6178 } catch (err) { 6179 console.warn('SwiftAuth: Could not check profile for onboarding:', err); 6180 // Continue with normal redirect if profile check fails 6181 } 6182 } 6183 6184 // Use swift_auth_return_url which is set when user clicks login from article 6185 const redirectUrl = sessionStorage.getItem('swift_auth_return_url'); 6186 sessionStorage.removeItem('swift_auth_return_url'); 6187 6188 if (redirectUrl && !redirectUrl.includes('/authentication') && !redirectUrl.includes('/logout')) { 6189 console.log('SwiftAuth: Redirecting to stored return URL:', redirectUrl); 6190 window.location.href = redirectUrl; 6191 } else { 6192 console.log('SwiftAuth: No return URL, redirecting to homepage'); 6193 window.location.href = '/'; 6194 } 6195 } else { 6196 throw new Error(response.error || 'Authentication failed'); 6197 } 6198 6199 } catch (err) { 6200 console.error('SwiftAuth: OAuth callback error', err); 6201 // Redirect to clean auth page so form is visible again with error 6202 window.location.href = '/authentication/?auth_error=' + encodeURIComponent(err.message || 'Sign-in failed. Please try again.'); 6203 } 6204 } 6205 6206 /** 6207 * Store OAuth tokens in localStorage for Cognito JS SDK compatibility 6208 * This allows getCurrentUser() to work on subsequent page loads 6209 * 6210 * @param {object} tokens - The tokens from Cognito token endpoint 6211 * @param {string} tokens.id_token - The ID token (JWT) 6212 * @param {string} tokens.access_token - The access token 6213 * @param {string} tokens.refresh_token - The refresh token (optional) 6214 */ 6215 function storeCognitoTokens(tokens) { 6216 const clientId = config.cognito?.clientId; 6217 if (!clientId || !tokens.id_token) { 6218 console.error('SwiftAuth: Cannot store tokens - missing clientId or id_token'); 6219 return; 6220 } 6221 6222 try { 6223 // Decode the ID token to get the username (sub) 6224 const tokenParts = tokens.id_token.split('.'); 6225 const payload = JSON.parse(atob(tokenParts[1])); 6226 const username = payload.sub || payload['cognito:username']; 6227 6228 if (!username) { 6229 console.error('SwiftAuth: Cannot store tokens - no username in token'); 6230 return; 6231 } 6232 6233 // Store tokens in the format Cognito JS SDK expects
6234 const keyPrefix = `CognitoIdentityServiceProvider.${clientId}`; 6235 6236 localStorage.setItem(`${keyPrefix}.${username}.idToken`, tokens.id_token); 6237 localStorage.setItem(`${keyPrefix}.${username}.accessToken`, tokens.access_token); 6238 if (tokens.refresh_token) { 6239 localStorage.setItem(`${keyPrefix}.${username}.refreshToken`, tokens.refresh_token); 6240 } 6241 localStorage.setItem(`${keyPrefix}.LastAuthUser`, username); 6242 6243 // Also store clock drift (required by SDK) 6244 localStorage.setItem(`${keyPrefix}.${username}.clockDrift`, '0'); 6245 6246 console.log('SwiftAuth: Cognito tokens stored in localStorage for user:', username); 6247 6248 } catch (err) { 6249 console.error('SwiftAuth: Failed to store Cognito tokens:', err.message); 6250 } 6251 } 6252 6253 /** 6254 * Exchange authorization code for tokens via Cognito's token endpoint 6255 */ 6256 async function exchangeCodeForTokens(code) { 6257 let domain = config.cognito?.domain; 6258 const clientId = config.cognito?.clientId; 6259 const redirectUri = window.location.origin + '/authentication/'; 6260 6261 // Strip https:// or http:// prefix if present 6262 if (domain) { 6263 domain = domain.replace(/^https?:\/\//, ''); 6264 } 6265 6266 const tokenUrl = `https://${domain}/oauth2/token`; 6267 6268 const params = new URLSearchParams({ 6269 grant_type: 'authorization_code', 6270 client_id: clientId, 6271 code: code, 6272 redirect_uri: redirectUri, 6273 }); 6274 6275 const response = await fetch(tokenUrl, { 6276 method: 'POST', 6277 headers: { 6278 'Content-Type': 'application/x-www-form-urlencoded', 6279 }, 6280 body: params.toString(), 6281 }); 6282 6283 if (!response.ok) { 6284 const errorText = await response.text(); 6285 console.error('Token exchange failed:', errorText); 6286 throw new Error('Failed to complete sign-in'); 6287 } 6288 6289 const tokens = await response.json(); 6290 // Returns: { id_token, access_token, refresh_token, expires_in, token_type } 6291 6292 return tokens; 6293 } 6294 6295 /** 6296 * Check for OAuth callback on page load 6297 * Should be called during initialization on /authentication/ page 6298 */ 6299 function checkOAuthCallback() { 6300 const urlParams = new URLSearchParams(window.location.search); 6301 if (urlParams.has('code') || urlParams.has('error')) { 6302 handleOAuthCallback(); 6303 return true; 6304 } 6305 return false; 6306 } 6307 6308 /** 6309 * Initialize social sign-in SDKs and button handlers 6310 * Call this when the authentication page loads 6311 */ 6312 function initSocialSignIn() { 6313 console.log('SwiftAuth: initSocialSignIn() called'); 6314 console.log('SwiftAuth: config.social =', config.social); 6315 6316 // Initialize Google Identity Services 6317 if (config.social?.googleEnabled) { 6318 console.log('SwiftAuth: Google is enabled, checking for SDK...'); 6319 if (typeof google !== 'undefined' && google.accounts) { 6320 console.log('SwiftAuth: Google SDK already loaded'); 6321 initGoogleSignIn(); 6322 } else { 6323 console.log('SwiftAuth: Google SDK not loaded yet, waiting...'); 6324 // Wait for Google SDK to load 6325 window.addEventListener('load', () => { 6326 setTimeout(() => { 6327 if (typeof google !== 'undefined' && google.accounts) { 6328 console.log('SwiftAuth: Google SDK loaded after wait'); 6329 initGoogleSignIn(); 6330 } else { 6331 console.log('SwiftAuth: Google SDK still not available'); 6332 } 6333 }, 100); 6334 }); 6335 } 6336 } else { 6337 console.log('SwiftAuth: Google sign-in is NOT enabled'); 6338 } 6339 6340 // Facebook: no SDK needed â Cognito handles the full OAuth flow 6341 6342 // Initialize social sign-in button handlers using event delegation 6343 initSocialSignInButtons(); 6344 } 6345 6346 /** 6347 * Initialize social sign-in button click handlers 6348 * Uses data attributes for buttons: [data-swift-google], [data-swift-facebook] 6349 */ 6350 function initSocialSignInButtons() { 6351 console.log('SwiftAuth: Setting up social sign-in button handlers...'); 6352 6353 // Show/hide social sign-in containers and buttons based on config 6354 // (This also runs in authentication-page.html inline script, but we do it 6355 // here as well to ensure it works even if inline scripts are stripped) 6356 if (config.social?.googleEnabled || config.social?.facebookEnabled) { 6357 document.querySelectorAll('.swift-social-signin').forEach(el => { 6358 el.style.display = 'block'; 6359 }); 6360 } 6361 if (!config.social?.googleEnabled) { 6362 document.querySelectorAll('[data-swift-google]').forEach(btn => { 6363 btn.style.display = 'none'; 6364 }); 6365 } 6366 if (!config.social?.facebookEnabled) { 6367 document.querySelectorAll('[data-swift-facebook]').forEach(btn => { 6368 btn.style.display = 'none'; 6369 }); 6370 } 6371 6372 // Log what buttons exist on the page 6373 const googleBtns = document.querySelectorAll('[data-swift-google]'); 6374 const facebookBtns = document.querySelectorAll('[data-swift-facebook]'); 6375 console.log('SwiftAuth: Found Google buttons:', googleBtns.length); 6376 console.log('SwiftAuth: Found Facebook buttons:', facebookBtns.length); 6377 6378 // Direct attachment to existing buttons (more reliable) 6379 googleBtns.forEach((btn, i) => { 6380 console.log('SwiftAuth: Attaching click handler to Google button', i); 6381 btn.addEventListener('click', (e) => { 6382 e.preventDefault(); 6383 e.stopPropagation(); 6384 console.log('SwiftAuth: Google button clicked (direct handler)!'); 6385 signInWithGoogle(); 6386 }); 6387 }); 6388 6389 facebookBtns.forEach((btn, i) => { 6390 console.log('SwiftAuth: Attaching click handler to Facebook button', i); 6391 btn.addEventListener('click', (e) => { 6392 e.preventDefault(); 6393 e.stopPropagation(); 6394 console.log('SwiftAuth: Facebook button clicked (direct handler)!'); 6395 signInWithFacebook(); 6396 }); 6397 }); 6398 6399 // Also use event delegation for dynamically added buttons 6400 document.addEventListener('click', (e) => { 6401 // Google sign-in button 6402 const googleBtn = e.target.closest('[data-swift-google]'); 6403 if (googleBtn) { 6404 e.preventDefault(); 6405 e.stopPropagation(); 6406 console.log('SwiftAuth: Google sign-in button clicked (delegation)!'); 6407 console.log('SwiftAuth: Social config:', config.social); 6408 signInWithGoogle(); 6409 return; 6410 } 6411 6412 // Facebook sign-in button 6413 const facebookBtn = e.target.closest('[data-swift-facebook]'); 6414 if (facebookBtn) { 6415 e.preventDefault(); 6416 e.stopPropagation(); 6417 console.log('SwiftAuth: Facebook sign-in button clicked (delegation)!'); 6418 console.log('SwiftAuth: Social config:', config.social); 6419 signInWithFacebook(); 6420 return; 6421 } 6422 }); 6423 6424 console.log('SwiftAuth: Social sign-in button handlers initialized'); 6425 } 6426 6427 /** 6428 * Check if social sign-in is enabled 6429 */ 6430 function isSocialSignInEnabled() { 6431 return config.social?.googleEnabled || config.social?.facebookEnabled; 6432 } 6433 6434 // ========================================================================= 6435 // Initialization 6436 // ========================================================================= 6437 6438 /** 6439 * Refresh auth status from server 6440 * This handles cached pages where server-rendered config may be stale 6441 */ 6442 async function refreshAuthStatus() { 6443 try { 6444 const response = await ajaxRequest('swift_auth_status', {}, 'POST', false); 6445 6446 if (response.success) { 6447 const newAuthLevel = response.auth_level || 'anonymous'; 6448 const newSub = response.sub || null; 6449 6450 // Never let the server downgrade a lite-login session. 6451 // The server has no knowledge of localStorage tokens. 6452 if (state.authLevel === 'lite' && newAuthLevel === 'anonymous') { 6453 return; 6454 } 6455 6456 // Only update if state changed 6457 if (state.authLevel !== newAuthLevel || state.sub !== newSub) { 6458 console.log('SwiftAuth: Auth state updated from server', { 6459 old: state.authLevel, 6460 new: newAuthLevel, 6461 }); 6462 6463 state.authLevel = newAuthLevel; 6464 state.sub = newSub; 6465 state.sessionExpires = response.expires_at || null; 6466 6467 // Re-update UI with correct state 6468 updateUI(); 6469 } 6470 } 6471 } catch (err) { 6472 console.error('SwiftAuth: Failed to refresh auth status', err); 6473 } 6474 } 6475 6476 /** 6477 * Check local Cognito session immediately on load 6478 * Updates state and UI based on local storage, without hitting server 6479 */ 6480 function checkLocalSession() { 6481 if (!window.swiftAuthUserPool) { 6482 return; 6483 } 6484 6485 // If logout() was called before init(), don't re-elevate 6486 if (state.loggedOut) { 6487 console.log('SwiftAuth: Skipping local session check â logout in progress'); 6488 return; 6489 } 6490 6491 const cognitoUser = window.swiftAuthUserPool.getCurrentUser(); 6492 6493 if (cognitoUser) { 6494 // We have a user in localStorage, assuming logged in for now 6495 // The actual token validity will be checked by initUserDataManagerFromCognitoSession 6496 // But for UI "flash" prevention, this is a good signal 6497 console.log('SwiftAuth: Local Cognito user found:', cognitoUser.getUsername()); 6498 6499 // If config says anonymous but we have a user, optimistically set to full 6500 // This fixes the case where cached page says anonymous but user is logged in 6501 if (state.authLevel === 'anonymous') { 6502 console.log('SwiftAuth: Optimistically setting auth level to full based on local session'); 6503 state.authLevel = 'full'; 6504 state.sub = cognitoUser.getUsername(); // sub is usually the username in Cognito 6505 updateUI(); 6506 } 6507 } else { 6508 // No user in localStorage 6509 console.log('SwiftAuth: No local Cognito user found'); 6510 6511 // If config says logged in but we have no user, downgrade to anonymous. 6512 // Do NOT downgrade 'lite' â lite-login sessions have no Cognito token by design. 6513 if (state.authLevel === 'full') { 6514 console.log('SwiftAuth: Downgrading auth level to anonymous based on missing local session'); 6515 state.authLevel = 'anonymous'; 6516 state.sub = null; 6517 updateUI(); 6518 } 6519 } 6520 } 6521 6522 /** 6523 * Initialize the plugin 6524 */
6525 async function init() { 6526 console.log('SwiftAuth: init() starting...'); 6527 6528 if (state.initialized) { 6529 console.log('SwiftAuth: Already initialized, skipping'); 6530 return; 6531 } 6532 state.initialized = true; 6533 6534 // Initialize Cognito 6535 initCognito(); 6536 6537 // Check local Cognito session for optimistic auth (no network) 6538 checkLocalSession(); 6539 6540 // Check localStorage for an existing magic (lite-login) token. 6541 // This runs before any network calls so the UI is correct immediately. 6542 if (state.authLevel === 'anonymous') { 6543 const magicSession = getMagicToken(); 6544 if (magicSession) { 6545 state.authLevel = 'lite'; 6546 state.sub = magicSession.payload.sub || null; 6547 console.log('SwiftAuth: Restored lite-login from localStorage'); 6548 } 6549 } 6550 6551 // Signal that initialization is complete (optimistic state) 6552 window.SwiftAuth.isInitialized = true; 6553 window.dispatchEvent(new CustomEvent('swiftauth:initialized')); 6554 6555 // Update UI with optimistic state 6556 updateUI(); 6557 6558 // Check for OAuth callback FIRST (before other init) 6559 // This handles return from Google/Facebook sign-in 6560 const authPage = document.querySelector('[data-authentication]'); 6561 console.log('SwiftAuth: Auth page element found:', !!authPage); 6562 6563 if (authPage) { 6564 // Check if this is an OAuth callback (returning from Cognito) 6565 if (checkOAuthCallback()) { 6566 // OAuth callback is being processed, wait for it 6567 console.log('SwiftAuth: Processing OAuth callback...'); 6568 return; // Don't continue init - handleOAuthCallback will handle everything 6569 } 6570 6571 // Show auth_error from failed OAuth redirect if present 6572 const authError = new URLSearchParams(window.location.search).get('auth_error'); 6573 if (authError) { 6574 showSocialAuthError(authError); 6575 window.history.replaceState({}, '', '/authentication/'); 6576 } 6577 6578 // Initialize social sign-in SDKs (Google Identity Services, Facebook SDK) 6579 console.log('SwiftAuth: Initializing social sign-in on auth page...'); 6580 initSocialSignIn(); 6581 } else { 6582 console.log('SwiftAuth: Not on auth page, skipping social sign-in init'); 6583 } 6584 6585 // Initialize UI handlers 6586 initLoginButtons(); 6587 initBookmarkButtons(); 6588 initWhyRegisterButtons(); 6589 initDeveloperShortcuts(); 6590 initSubscribePage(); 6591 initManageSubscription(); 6592 6593 // ALWAYS initialize social sign-in button handlers (they use event delegation) 6594 // This ensures buttons work even if added dynamically 6595 initSocialSignInButtons(); 6596 6597 // === PARALLEL ASYNC FETCHES === 6598 // Fetch auth status and wall config in parallel. 6599 // These are no longer embedded in HTML (CloudFront cache-safe). 6600 // The nonce is NOT fetched here â it's resolved lazily on the first 6601 // write action (see fetchNonce/ajaxRequest) so anonymous page loads 6602 // don't boot WordPress for a nonce they never use. 6603 // 6604 // Skip the uncached swift_auth_status round-trip when client state is 6605 // already anonymous. checkLocalSession()/getMagicToken() ran above and 6606 // set authLevel to 'full'/'lite' if there is ANY session evidence 6607 // (Cognito localStorage session or magic token). A still-'anonymous' 6608 // visitor has none, so the server can only echo 'anonymous' â booting 6609 // WordPress for that on every pageview is pure waste. 6610 const initialFetches = [fetchWallConfig()]; 6611 if (state.authLevel !== 'anonymous') { 6612 initialFetches.push(refreshAuthStatus()); 6613 } else { 6614 console.log('SwiftAuth: Anonymous client state â skipping server auth_status check'); 6615 } 6616 await Promise.all(initialFetches); 6617 6618 // Redeem ?mlt= magic link token if present (redeemMagicLink fetches the 6619 // nonce itself via ajaxRequest when an mlt token is in the URL). 6620 // redeemMagicLink() strips the param from the URL and updates state. 6621 await redeemMagicLink(); 6622 6623 // Debug: log config (after async data is loaded) 6624 console.log('SwiftAuth: Initialized with config', { 6625 authLevel: state.authLevel, 6626 whyRegisterHtml: config.whyRegisterHtml ? '(set, ' + config.whyRegisterHtml.length + ' chars)' : '(empty)', 6627 socialEnabled: isSocialSignInEnabled(), 6628 }); 6629 6630 // Update UI with confirmed auth state 6631 updateUI(); 6632 6633 // Show profile shimmer IMMEDIATELY if on profile page (before any async work) 6634 // This gives instant visual feedback while UserDataManager initializes 6635 const profileRootEarly = document.getElementById('swift-profile-root'); 6636 if (profileRootEarly) { 6637 if (state.authLevel === 'full') { 6638 renderProfileLoading(profileRootEarly); 6639 } else if (state.authLevel === 'anonymous') { 6640 renderProfileLoginRequired(profileRootEarly); 6641 } 6642 } 6643 6644 // Always try to initialize UserDataManager if user might be logged into Cognito 6645 // This handles: 1) anonymous users with Cognito session, 2) logged in users needing DynamoDB 6646 await initUserDataManagerFromCognitoSession(); 6647 6648 // === WALL CHECKS (early) === 6649 // Auth is stable, UserDataManager ready, wall config loaded â check walls 6650 6651 // Resolve subscribe nav btn on all pages using cached entitlement. 6652 // Article pages get overridden below with fresh DynamoDB data from checkPaywall(). 6653 if (config.paywall?.enabled) { 6654 resolveSubscribeNavBtn(evaluateEntitlement(getCachedSubscription()).hasActive); 6655 } 6656 6657 const article = document.querySelector('.p402_premium'); 6658 if (article) { 6659 // Free content bypasses all walls 6660 const contentCategory = document.querySelector('meta[name="__sync_contentCategory"]'); 6661 const isFreeContent = contentCategory && contentCategory.getAttribute('content') === 'Free'; 6662 6663 if (isFreeContent) { 6664 console.log('SwiftAuth: Free content â walls bypassed'); 6665 } else { 6666 // Regwall is for anonymous users only 6667 if (config.regwall?.enabled && state.authLevel === 'anonymous') { 6668 checkRegwall(); 6669 } 6670 // Paywall runs for all users; lite-login goes to server entitlement check 6671 if (config.paywall?.enabled) { 6672 await checkPaywall(); 6673 } 6674 } 6675 6676 // Record pageview for reading history (authenticated users only) 6677 console.log('[PV-DIAG] Pageview gate check:', { 6678 authLevel: state.authLevel, 6679 dynamoEnabled: isDirectDynamoEnabled(), 6680 hasArticle: !!article, 6681 userDataManagerReady: state.userDataManagerReady, 6682 hasUserDataManager: !!state.userDataManager, 6683 udmIsReady: state.userDataManager?.isReady?.(), 6684 userId: state.userDataManager?.getUserId?.(), 6685 pubID: config.pubID, 6686 }); 6687 if (state.authLevel === 'full' && isDirectDynamoEnabled()) { 6688 try { 6689 const trackHistory = await state.userDataManager.getReadingHistoryPreference(); 6690 console.log('[PV-DIAG] trackReadingHistory preference:', trackHistory); 6691 6692 const pubID = config.pubID || '';
6693 const key = pubID + 'readingHistorySetting'; 6694 localStorage.setItem(key, trackHistory.toString()); 6695 6696 if (trackHistory) { 6697 const meta = getArticleMeta(); 6698 console.log('[PV-DIAG] Article meta:', { 6699 postId: meta.postId || meta.id, 6700 url: meta.url, 6701 title: meta.title ? meta.title.substring(0, 50) : '(none)', 6702 }); 6703 if (meta.url) { 6704 console.log('[PV-DIAG] Recording pageview to DynamoDB...'); 6705 try { 6706 await recordPageview({ 6707 postId: meta.postId || meta.id, 6708 articleUrl: meta.url, 6709 articleTitle: meta.title, 6710 articleImage: meta.image 6711 }); 6712 console.log('[PV-DIAG] Pageview recorded successfully'); 6713 } catch (pvErr) { 6714 console.error('[PV-DIAG] recordPageview THREW:', pvErr); 6715 } 6716 } else { 6717 console.warn('[PV-DIAG] No meta.url â skipping pageview'); 6718 } 6719 } else { 6720 console.warn('[PV-DIAG] trackHistory is false â skipping pageview'); 6721 } 6722 } catch (err) { 6723 console.warn('[PV-DIAG] Reading history preference check failed:', err); 6724 } 6725 } else { 6726 console.warn('[PV-DIAG] Pageview gate FAILED â not recording', { 6727 reason: state.authLevel !== 'full' ? 'authLevel is ' + state.authLevel : 'DynamoDB not enabled', 6728 }); 6729 } 6730 } 6731 6732 // Now that DynamoDB is ready, update bookmark button states 6733 if (isDirectDynamoEnabled()) { 6734 await updateBookmarkButtonStates(); 6735 } 6736 6737 // Enhance subscribe page now that UserDataManager is ready (entitlement check + email prefill) 6738 await updateSubscribePage(); 6739 6740 // Initialize profile page if on profile page 6741 await initProfilePage(); 6742 6743 // Load followed topics for authenticated users (if not already loaded on profile page) 6744 // This is needed for follow buttons on other pages 6745 // OPTIMIZED: Use lightweight topics-only fetch instead of full profile 6746 if (state.authLevel === 'full' && state.followedTopics.length === 0) { 6747 const hasFollowButtons = document.querySelector('[data-topic]'); 6748 if (hasFollowButtons && isDirectDynamoEnabled()) { 6749 try { 6750 const rawTopics = await state.userDataManager.getFollowedTopicsOnly(); 6751 // Filter and set followed topics 6752 state.followedTopics = (rawTopics || []).filter(topic => { 6753 if (!topic) return false; 6754 const topicId = topic.topicId || topic.id || ''; 6755 const topicName = topic.topicName || topic.name || ''; 6756 return topicId || topicName; 6757 }); 6758 } catch (err) { 6759 console.warn('SwiftAuth: Could not load followed topics', err); 6760 } 6761 } 6762 } 6763 6764 // Initialize follow topic buttons (after topics are loaded) 6765 initFollowTopicButtons(); 6766 initAuthorFollowContainers(); 6767 6768 // Handle verification code in URL 6769 const urlParams = new URLSearchParams(window.location.search); 6770 const verificationCode = urlParams.get('code'); 6771 6772 if (verificationCode && authPage) { 6773 // Auto-fill verification code 6774 const codeInput = authPage.querySelector('[name="verification_code"]'); 6775 if (codeInput) { 6776 codeInput.value = verificationCode; 6777 } 6778 } 6779 6780 // Handle forgot password parameters in URL
6781 const forgotPasswordCode = urlParams.get('forgot_password_code'); 6782 const forgotPasswordUsername = urlParams.get('username'); 6783 const forgotPasswordEmail = urlParams.get('email'); 6784 6785 if (forgotPasswordCode && authPage) { 6786 handleForgotPasswordLink(authPage, { 6787 code: forgotPasswordCode, 6788 username: forgotPasswordUsername, 6789 email: forgotPasswordEmail 6790 }); 6791 } 6792 } 6793 6794 // ========================================================================= 6795 // Subscribe Page 6796 // ========================================================================= 6797 6798 /** 6799 * Instant render of subscribe page based on current state (no async) 6800 */ 6801 function initSubscribePage() { 6802 const root = document.getElementById('swift-subscribe-root'); 6803 if (!root) return; 6804 6805 const stripeConfig = config.stripe; 6806 if (!stripeConfig || !stripeConfig.enabled || !stripeConfig.plans || stripeConfig.plans.length === 0) { 6807 root.innerHTML = '<p>Subscription plans are not currently available.</p>'; 6808 return; 6809 } 6810 6811 // Check for success redirect 6812 const urlParams = new URLSearchParams(window.location.search); 6813 if (urlParams.get('success') === 'true') { 6814 // Read return_to from URL param or cookie 6815 let returnTo = urlParams.get('return_to') || ''; 6816 if (!returnTo) { 6817 const match = document.cookie.match('(?:^|; )swift_subscribe_return_to=([^;]*)'); 6818 if (match) returnTo = decodeURIComponent(match[1]); 6819 } 6820 // Clear the cookie 6821 document.cookie = 'swift_subscribe_return_to=;path=/;max-age=0'; 6822 6823 if (returnTo) { 6824 root.innerHTML = ` 6825 <div class="swift-subscribe-success"> 6826 <h2>Thank you for subscribing!</h2> 6827 <p>Your subscription is being activated. Redirecting you back to your article...</p> 6828 </div> 6829 `; 6830 setTimeout(() => { window.location.href = returnTo; }, 2000); 6831 } else { 6832 root.innerHTML = ` 6833 <div class="swift-subscribe-success"> 6834 <h2>Thank you for subscribing!</h2> 6835 <p>Your subscription is being activated. You should have full access within a few moments.</p> 6836 <p><a href="/" class="swift-btn swift-btn-primary">Go to Homepage</a></p> 6837 </div> 6838 `; 6839 } 6840 return; 6841 } 6842 6843 // Stash return_to article URL as cookie for use after Stripe redirect 6844 const returnTo = urlParams.get('return_to') || ''; 6845 if (returnTo) { 6846 document.cookie = 'swift_subscribe_return_to=' + encodeURIComponent(returnTo) + ';path=/;max-age=3600;SameSite=Lax'; 6847 } 6848 6849 // Require full login 6850 if (state.authLevel !== 'full' || !state.sub) { 6851 root.innerHTML = ` 6852 <div class="swift-subscribe-login-required"> 6853 <h2>Sign in to subscribe</h2> 6854 <p>Please sign in or create an account to choose a subscription plan.</p> 6855 <a href="/authentication/" class="swift-btn swift-btn-primary" id="swift-subscribe-login-btn">Sign In</a> 6856 </div> 6857 `; 6858 const subscribeLoginBtn = document.getElementById('swift-subscribe-login-btn'); 6859 if (subscribeLoginBtn) { 6860 subscribeLoginBtn.addEventListener('click', () => { 6861 setReturnUrl(window.location.href); 6862 }); 6863 } 6864 return; 6865 } 6866 6867 // Logged in â try cached subscription for instant render 6868 const cachedSub = getCachedSubscription(); 6869 if (cachedSub) { 6870 const evalResult = evaluateEntitlement(cachedSub); 6871 if (evalResult.hasActive) { 6872 renderSubscribeCurrentPlan(root, cachedSub, evalResult); 6873 return; 6874 } 6875 } 6876 6877 // No cached active subscription â render plan cards (email patched later by updateSubscribePage) 6878 const cachedToken = getCachedIdTokenPayload(); 6879 renderSubscribePlanCards(root, stripeConfig, cachedToken?.email || ''); 6880 } 6881 6882 /**
6883 * Render plan cards into the subscribe root 6884 */ 6885 function renderSubscribePlanCards(root, stripeConfig, email) { 6886 email = email || ''; 6887 6888 // Check if any plan has ACH pricing â if so, show the payment method toggle 6889 const hasAchOption = stripeConfig.plans.some(p => p.stripePriceIdAch); 6890 let paymentMethod = 'cc'; // default to Credit Card 6891 6892 function getDisplayPrice(plan) { 6893 if (paymentMethod === 'ach') { 6894 return plan.priceAch || plan.price || ''; 6895 } 6896 return plan.priceCC || plan.price || ''; 6897 } 6898 6899 function getPriceId(plan) { 6900 if (paymentMethod === 'ach' && plan.stripePriceIdAch) { 6901 return plan.stripePriceIdAch; 6902 } 6903 return plan.stripePriceId; 6904 } 6905 6906 function buildPlanCardsHtml() { 6907 return stripeConfig.plans.map((plan, idx) => { 6908 const isFeatured = !!plan.featured; 6909 const cardClass = 'swift-plan-card' + (isFeatured ? ' swift-plan-featured' : ''); 6910 const btnClass = 'swift-btn swift-plan-cta ' + (isFeatured ? 'swift-btn-primary' : 'swift-btn-secondary'); 6911 const badge = isFeatured ? '<div class="swift-plan-badge">Best Value</div>' : '<div class="swift-plan-badge"></div>'; 6912 const displayPrice = getDisplayPrice(plan); 6913 const priceHtml = displayPrice 6914 ? '<div class="swift-plan-price"><span class="swift-plan-currency">$</span>' + escapeHtml(displayPrice) + '</div>' 6915 : ''; 6916 const periodHtml = plan.period ? '<div class="swift-plan-period">' + escapeHtml(plan.period) + '</div>' : ''; 6917 6918 return ` 6919 <div class="${cardClass}"> 6920 ${badge} 6921 <h3 class="swift-plan-label">${escapeHtml(plan.label)}</h3> 6922 ${priceHtml} 6923 ${periodHtml} 6924 <button class="${btnClass}" data-plan-index="${idx}">Subscribe</button> 6925 </div> 6926 `; 6927 }).join(''); 6928 } 6929 6930 const subTitle = config.paywall?.subscribePageTitle || 'Choose your plan'; 6931 const subSubtitle = config.paywall?.subscribePageSubtitle || ''; 6932 6933 const toggleHtml = hasAchOption ? ` 6934 <div class="swift-payment-step"> 6935 <div class="swift-step-header"> 6936 <span class="swift-step-label">Step 1 — How would you like to pay?</span> 6937 </div> 6938 <div class="swift-payment-options"> 6939 <div class="swift-payment-option" data-method="ach"> 6940 <span class="swift-radio-dot"></span> 6941 <span class="swift-payment-option-name">Bank transfer (ACH)</span> 6942 <span class="swift-fee-badge swift-fee-none">NO FEE</span> 6943 </div> 6944 <div class="swift-payment-option swift-payment-option-active" data-method="cc"> 6945 <span class="swift-radio-dot"></span> 6946 <span class="swift-payment-option-name">Credit or debit card</span> 6947 <span class="swift-fee-badge swift-fee-paid">+2% FEE</span> 6948 </div> 6949 </div> 6950 </div> 6951 ` : ''; 6952 6953 root.innerHTML = ` 6954 <div class="swift-subscribe-header"> 6955 <h2 class="swift-subscribe-title">${subTitle}</h2> 6956 ${subSubtitle ? `<p class="swift-subscribe-subtitle">${subSubtitle}</p>` : ''} 6957 </div> 6958 ${toggleHtml} 6959 ${hasAchOption ? '<p class="swift-plan-step-label">Step 2 — Pick a plan</p>' : ''} 6960 <div class="swift-plan-grid"> 6961 ${buildPlanCardsHtml()} 6962 </div> 6963 <p class="swift-stripe-secured">Payments are secured by Stripe</p> 6964 `; 6965 6966 // Toggle handler 6967 if (hasAchOption) { 6968 root.querySelectorAll('.swift-payment-option').forEach(option => { 6969 option.addEventListener('click', () => { 6970 paymentMethod = option.dataset.method; 6971 root.querySelectorAll('.swift-payment-option').forEach(o => o.classList.remove('swift-payment-option-active')); 6972 option.classList.add('swift-payment-option-active'); 6973 const grid = root.querySelector('.swift-plan-grid'); 6974 if (grid) grid.innerHTML = buildPlanCardsHtml(); 6975 bindCheckoutHandlers(); 6976 }); 6977 }); 6978 } 6979 6980 function bindCheckoutHandlers() { 6981 root.querySelectorAll('[data-plan-index]').forEach(btn => { 6982 btn.addEventListener('click', async () => { 6983 const plan = stripeConfig.plans[btn.dataset.planIndex]; 6984 if (!plan) return; 6985 6986 btn.disabled = true; 6987 btn.textContent = 'Redirecting...'; 6988 6989 try { 6990 let recaptchaToken = ''; 6991 const rcSiteKey = window.swiftAuthConfig && window.swiftAuthConfig.recaptcha && window.swiftAuthConfig.recaptcha.siteKey; 6992 if (rcSiteKey && typeof grecaptcha !== 'undefined') { 6993 try { 6994 recaptchaToken = await grecaptcha.execute(rcSiteKey, { action: 'checkout' }); 6995 } catch (rcErr) { 6996 console.warn('reCAPTCHA token failed:', rcErr); 6997 } 6998 } 6999 7000 let returnTo = ''; 7001 const cookieMatch = document.cookie.match('(?:^|; )swift_subscribe_return_to=([^;]*)'); 7002 if (cookieMatch) returnTo = decodeURIComponent(cookieMatch[1]); 7003 7004 let successUrl = window.location.origin + '/subscribe/?success=true'; 7005 if (returnTo) successUrl += '&return_to=' + encodeURIComponent(returnTo); 7006 7007 const conversion = getConversionSource(); 7008 const checkoutData = { 7009 price_id: getPriceId(plan), 7010 success_url: successUrl, 7011 cancel_url: window.location.href, 7012 identity_pool_id: state.userDataManager ? state.userDataManager.userPoolSub : '', 7013 conversion_source: conversion.source, 7014 conversion_url: conversion.url, 7015 }; 7016 if (recaptchaToken) { 7017 checkoutData.recaptcha_token = recaptchaToken; 7018 } 7019 const result = await ajaxRequest('swift_create_checkout', checkoutData); 7020 if (result.success && result.checkout_url) { 7021 window.location.href = result.checkout_url; 7022 } else { 7023 btn.textContent = 'Subscribe';
7024 btn.disabled = false; 7025 alert(result.error || 'Could not start checkout. Please try again.'); 7026 } 7027 } catch (err) { 7028 btn.textContent = 'Subscribe'; 7029 btn.disabled = false; 7030 alert('Could not start checkout. Please try again.'); 7031 } 7032 }); 7033 }); 7034 } 7035 7036 // Initial bind 7037 bindCheckoutHandlers(); 7038 } 7039 7040 /** 7041 * Render "Your Subscription" view for active subscribers 7042 * Reusable by both initSubscribePage (cached) and updateSubscribePage (DynamoDB) 7043 */ 7044 function renderSubscribeCurrentPlan(root, entitlement, evalResult) { 7045 const formatDate = (dateVal) => { 7046 if (!dateVal) return 'N/A'; 7047 const date = new Date(typeof dateVal === 'number' ? dateVal * 1000 : dateVal); 7048 return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); 7049 }; 7050 7051 const getTierInfo = (tier) => { 7052 if (tier === 'subscriber') return { class: 'success', label: 'Subscriber' }; 7053 if (tier === 'employee') return { class: 'info', label: 'Employee' }; 7054 if (tier === 'gratis') return { class: 'secondary', label: 'Complimentary' }; 7055 if (tier === 'trial') return { class: 'warning', label: 'Trial' }; 7056 return { class: 'default', label: 'Free' }; 7057 }; 7058 7059 const tierInfo = getTierInfo(evalResult.tier); 7060 7061 root.innerHTML = ` 7062 <div class="swift-subscribe-current"> 7063 <div class="swift-profile-section"> 7064 <div class="swift-section-header"> 7065 <h3>Your Subscription</h3> 7066 <span class="swift-tier-badge swift-tier-${tierInfo.class}">${tierInfo.label}</span> 7067 </div> 7068 <div class="swift-section-content"> 7069 <div class="swift-subscription-status swift-status-active"> 7070 <p class="swift-status-message">Your subscription is <strong>active</strong>.</p> 7071 ${evalResult.endDate ? `<p class="swift-status-detail">${evalResult.autoRenew ? 'Renews' : 'Expires'}: ${formatDate(evalResult.endDate)}</p>` : ''} 7072 </div> 7073 <div class="swift-subscription-actions"> 7074 <button class="swift-btn swift-btn-primary swift-subscribe-manage-btn">Manage Subscription</button> 7075 </div> 7076 </div> 7077 </div> 7078 </div> 7079 `; 7080 7081 const manageBtn = root.querySelector('.swift-subscribe-manage-btn'); 7082 if (manageBtn) { 7083 manageBtn.addEventListener('click', async () => { 7084 manageBtn.disabled = true; 7085 manageBtn.textContent = 'Opening...'; 7086 try { 7087 const result = await ajaxRequest('swift_checkout_portal', { 7088 return_url: window.location.href, 7089 }); 7090 if (result.success && result.portal_url) { 7091 window.location.href = result.portal_url; 7092 } else { 7093 manageBtn.textContent = 'Manage Subscription'; 7094 manageBtn.disabled = false; 7095 alert(result.error || 'Could not open subscription portal. Please try again.'); 7096 } 7097 } catch (err) { 7098 manageBtn.textContent = 'Manage Subscription'; 7099 manageBtn.disabled = false; 7100 alert('Could not open subscription portal. Please try again.'); 7101 } 7102 }); 7103 } 7104 } 7105 7106 /** 7107 * Deferred enhancement: check entitlement + patch email into plan links 7108 * Runs after UserDataManager is ready. Corrects stale cache if needed. 7109 */ 7110 async function updateSubscribePage() { 7111 const root = document.getElementById('swift-subscribe-root'); 7112 if (!root) return; 7113 7114 const stripeConfig = config.stripe; 7115 if (!stripeConfig || !stripeConfig.enabled) return; 7116 7117 // Only enhance for logged-in users who saw plan cards 7118 if (state.authLevel !== 'full' || !state.sub) return; 7119 7120 // Check for existing active subscription from DynamoDB 7121 if (state.userDataManager) { 7122 try { 7123 const entitlement = await state.userDataManager.getSubscriptionOnly(); 7124 const evalResult = evaluateEntitlement(entitlement); 7125 7126 if (evalResult.hasActive) { 7127 // Active subscription â render (or re-render) current plan view 7128 renderSubscribeCurrentPlan(root, entitlement, evalResult); 7129 return; 7130 } 7131 7132 // DynamoDB says no active entitlement â if cache showed active, correct it 7133 const showingCurrentPlan = root.querySelector('.swift-subscribe-current'); 7134 if (showingCurrentPlan) { 7135 // Stale cache correction: re-render as plan cards
7136 console.log('SwiftAuth: Cached subscription was stale, showing plan cards'); 7137 } 7138 7139 // Fall through to render/update plan cards 7140 } catch (err) { 7141 console.error('SwiftAuth: Subscribe page entitlement check error:', err); 7142 } 7143 } 7144 7145 // No active subscription â render plan cards with email 7146 let email = ''; 7147 try { 7148 const idToken = await getIdToken(); 7149 if (idToken) { 7150 const payload = JSON.parse(atob(idToken.split('.')[1])); 7151 email = payload.email || ''; 7152 } 7153 } catch (e) { 7154 // Ignore 7155 } 7156 7157 // Re-render plan cards with email (handles both fresh render and stale cache correction) 7158 renderSubscribePlanCards(root, stripeConfig, email); 7159 } 7160 7161 function initManageSubscription() { 7162 const root = document.getElementById('swift-manage-subscription-root'); 7163 if (!root) return; 7164 7165 if (state.authLevel !== 'full' || !state.sub) { 7166 root.innerHTML = '<p>Please <a href="/authentication/" data-swift-login>sign in</a> to manage your subscription.</p>'; 7167 return; 7168 } 7169 7170 root.innerHTML = ` 7171 <button class="swift-btn swift-btn-primary" id="swift-open-portal-btn">Open Subscription Portal</button> 7172 `; 7173 7174 const btn = document.getElementById('swift-open-portal-btn'); 7175 btn.addEventListener('click', async () => { 7176 btn.disabled = true; 7177 btn.textContent = 'Opening...'; 7178 try { 7179 const result = await ajaxRequest('swift_checkout_portal', { 7180 return_url: window.location.href, 7181 }); 7182 if (result.success && result.portal_url) { 7183 window.location.href = result.portal_url; 7184 } else { 7185 btn.textContent = 'Open Subscription Portal'; 7186 btn.disabled = false; 7187 alert(result.error || 'Could not open subscription portal. Please try again.'); 7188 } 7189 } catch (err) { 7190 btn.textContent = 'Open Subscription Portal'; 7191 btn.disabled = false; 7192 alert('Could not open subscription portal. Please try again.'); 7193 } 7194 }); 7195 } 7196 7197 function escapeHtml(str) { 7198 const div = document.createElement('div'); 7199 div.textContent = str; 7200 return div.innerHTML; 7201 } 7202 7203 // ========================================================================= 7204 // Public API 7205 // ========================================================================= 7206 7207 window.SwiftAuth = { 7208 // Auth methods 7209 login, 7210 register, 7211 confirmRegistration, 7212 forgotPassword, 7213 confirmPassword, 7214 resetPasswordAndLogin, 7215 changePassword, 7216 logout, 7217 refreshAuthStatus, 7218 checkCognitoSession, 7219 7220 // Social sign-in methods 7221 signInWithGoogle, 7222 signInWithFacebook, 7223 isSocialSignInEnabled, 7224 initSocialSignIn, 7225 7226 // Bookmark methods 7227 addBookmark, 7228 removeBookmark, 7229 toggleBookmark, 7230 checkBookmark, 7231 getBookmarks, 7232 7233 // Profile methods 7234 getProfile, 7235 updateProfile, 7236 initProfilePage, 7237 7238 // Topic methods 7239 followTopic, 7240 unfollowTopic, 7241 toggleFollowTopic, 7242 isTopicFollowed, 7243 7244 // Reading history methods 7245 recordPageview, 7246 clearReadingHistory, 7247 removeReadingHistoryItem, 7248 7249 // State 7250 getState: () => ({ ...state }), 7251 getConfig: () => ({ ...config }), 7252 isDirectDynamoEnabled, 7253 getForgotPasswordParams: () => state.forgotPasswordParams, 7254 getEntitlementStatus: () => evaluateEntitlement(getCachedSubscription()), 7255 7256 // UI 7257 updateUI, 7258 hideRegwall, 7259 unlockContent, 7260 showWhyRegisterModal,
7261 hideWhyRegisterModal, 7262 handleForgotPasswordLink, 7263 7264 // Utilities 7265 getArticleUrl, 7266 getArticleMeta, 7267 isPaywallSite, 7268 7269 // Return URL for post-login redirect 7270 setReturnUrl, 7271 getReturnUrl, 7272 clearReturnUrl, 7273 7274 // Stripe/Subscription 7275 initSubscribePage, 7276 initManageSubscription, 7277 7278 // Developer tools 7279 getSessionCount, 7280 setSessionCount, 7281 7282 // Direct DynamoDB access (for advanced use) 7283 getUserDataManager: () => state.userDataManager, 7284 7285 // Diagnostics (for debugging DynamoDB issues) 7286 getDynamoDBDiagnostics, 7287 }; 7288 7289 // Initialize on DOM ready 7290 console.log('SwiftAuth: Script loaded, document.readyState =', document.readyState); 7291 7292 if (document.readyState === 'loading') { 7293 console.log('SwiftAuth: Waiting for DOMContentLoaded...'); 7294 document.addEventListener('DOMContentLoaded', init); 7295 } else { 7296 console.log('SwiftAuth: DOM already ready, calling init() immediately'); 7297 init(); 7298 } 7299 7300 console.log('SwiftAuth: window.SwiftAuth is now available'); 7301 7302 // Initialize analytics if available 7303 if (window.SwiftAnalytics && config.analytics) { 7304 window.SwiftAnalytics.init(config.analytics); 7305 } 7306 7307})();
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.