1/* 2 * account-ui.js â Profile page + full Settings modal. 3 * 4 * Loaded after script.js so it can lean on the existing globals: 5 * currentUser, supabaseClientObj, updateHeaderAuth, 6 * wjLoadTrades / dashCollectTrades / dashComputeAggregate (stats), 7 * openTimezoneSettingsModal, openDataManagementModal, 8 * handleLogout / showAuthOverlay, navigateToPage. 9 * 10 * Kept standalone to avoid colliding with the journal-tab work happening 11 * inside the big script.js. Profile + avatar data live in Supabase 12 * auth user_metadata (same place the timezone preference is stored), so 13 * nothing here needs a DB migration. 14 */ 15(function () { 16 'use strict'; 17 18 var META_NAME = 'lj_display_name'; 19 var META_COLOR = 'lj_avatar_color'; 20 21 var AVATAR_COLORS = [ 22 '#EFBF04', // brand gold 23 '#10b981', // green 24 '#3b82f6', // blue 25 '#a855f7', // purple 26 '#ef4444', // red 27 '#f97316', // orange 28 '#14b8a6', // teal 29 '#ec4899' // pink 30 ]; 31 32 var USERNAME_RE = /^[a-z0-9_]{3,20}$/; 33 34 // Cached row from the public `profiles` table (source of truth for the 35 // profile fields once the migration is applied). Stays null until the 36 // first successful fetch â getters fall back to user_metadata meanwhile. 37 var _profile = null; 38 var _profileTableReady = false; // flips true once a profiles query succeeds 39 40 // ---------------------------------------------------------------- helpers 41 function getUser() { 42 return (window.currentUser && typeof window.currentUser === 'object') ? window.currentUser : null; 43 } 44 function getMeta() { 45 var u = getUser(); 46 return (u && u.user_metadata) ? u.user_metadata : {}; 47 } 48 function getDisplayName() { 49 var u = getUser(); 50 if (!u) return 'Guest'; 51 if (_profile && _profile.display_name && String(_profile.display_name).trim()) return String(_profile.display_name).trim(); 52 var m = getMeta(); 53 if (m[META_NAME] && String(m[META_NAME]).trim()) return String(m[META_NAME]).trim(); 54 // Public @handle is the identity â NEVER derive a name from the email 55 // (it's private and would leak to other users via profiles_auth_read). 56 var un = getUsername(); 57 if (un) return un; 58 // OAuth (Google) real name as a softer fallback before the generic one. 59 if (m.full_name && String(m.full_name).trim()) return String(m.full_name).trim(); 60 if (m.name && String(m.name).trim()) return String(m.name).trim(); 61 return 'Trader'; 62 } 63 function getAvatarColor() { 64 if (_profile && _profile.avatar_color) return _profile.avatar_color; 65 var m = getMeta(); 66 return m[META_COLOR] || AVATAR_COLORS[0]; 67 } 68 // Unique public @handle (user identity). Prefers the profiles table 69 // (source of truth once migrated); falls back to the user_metadata stopgap 70 // so the UI still works pre-migration. 71 function getUsername() { 72 if (_profile && _profile.username && String(_profile.username).trim()) return String(_profile.username).trim(); 73 var m = getMeta(); 74 var u = m.lj_username; 75 return (u && String(u).trim()) ? String(u).trim() : null; 76 } 77 function getBio() { 78 if (_profile && _profile.bio) return String(_profile.bio); 79 var m = getMeta(); 80 return m.lj_bio ? String(m.lj_bio) : ''; 81 } 82 function getInitial() { 83 var n = getDisplayName(); 84 return (n && n[0] ? n[0] : 'U').toUpperCase(); 85 } 86 function isAdmin() { 87 var u = getUser(); 88 // role lives in profiles, delivered via /api/me/entitlement -> window.__ljRole 89 // (currentUser.role may not be populated depending on load order). 90 return !!((u && u.role === 'admin') || window.__ljRole === 'admin'); 91 } 92 function getEmail() { 93 var u = getUser(); 94 return (u && u.email) ? u.email : 'â'; 95 } 96 function getMemberSince() { 97 var u = getUser(); 98 var iso = u && (u.created_at || (u.user_metadata && u.user_metadata.created_at)); 99 if (!iso) return 'â'; 100 try { 101 return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); 102 } catch (e) { return 'â'; } 103 } 104 function esc(s) { 105 return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { 106 return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; 107 }); 108 } 109 110 // ------------------------------------------------------------ persistence 111 function saveProfileMeta(updates) { 112 var u = getUser(); 113 if (!u || !window.supabaseClientObj) return Promise.resolve(false); 114 var next = Object.assign({}, u.user_metadata || {}, updates); 115 return window.supabaseClientObj.auth.updateUser({ data: next }) 116 .then(function (res) { 117 if (res && res.error) throw res.error; 118 if (res && res.data && res.data.user) { 119 window.currentUser = Object.assign({}, window.currentUser, res.data.user, { role: window.currentUser.role }); 120 } else { 121 window.currentUser.user_metadata = next; 122 } 123 return true; 124 }) 125 .catch(function (e) { 126 console.warn('[account-ui] Failed to persist profile metadata, applying locally.', e); 127 if (window.currentUser) window.currentUser.user_metadata = next; 128 return false;
129 }) 130 .then(function (ok) { 131 if (typeof window.updateHeaderAuth === 'function') window.updateHeaderAuth(); 132 refreshAvatarVisuals(); 133 return ok; 134 }); 135 } 136 137 // -------------------------------------------------- profiles table layer 138 // Fetch the current user's row from the public `profiles` table. Returns 139 // null (and leaves _profileTableReady false) if the table isn't migrated 140 // yet, so callers keep using the user_metadata fallback. 141 function fetchProfile() { 142 var u = getUser(); 143 if (!u || !window.supabaseClientObj) return Promise.resolve(null); 144 return window.supabaseClientObj 145 .from('profiles') 146 .select('username,display_name,avatar_color,bio,role,created_at') 147 .eq('id', u.id) 148 .maybeSingle() 149 .then(function (res) { 150 if (res && res.error) throw res.error; 151 _profileTableReady = true; 152 _profile = res ? res.data : null; 153 return backfillProfileFromMeta().then(function () { return _profile; }); 154 }) 155 .catch(function (e) { 156 // 42703 = column missing, 42P01 = table missing â not migrated. 157 console.warn('[account-ui] profiles table not ready yet (pre-migration?).', e && e.message ? e.message : e); 158 _profileTableReady = false; 159 return null; 160 }); 161 } 162 163 // One-time copy of the legacy user_metadata values into the profiles row 164 // so nothing is lost when we switch the source of truth to the table. 165 function backfillProfileFromMeta() { 166 if (!_profileTableReady || !_profile) return Promise.resolve(); 167 var m = getMeta(); 168 var patch = {}; 169 if (!_profile.display_name && m[META_NAME]) patch.display_name = m[META_NAME]; 170 if (!_profile.avatar_color && m[META_COLOR]) patch.avatar_color = m[META_COLOR]; 171 if (!_profile.bio && m.lj_bio) patch.bio = m.lj_bio; 172 if (!_profile.username && m.lj_username) patch.username = m.lj_username; 173 if (!Object.keys(patch).length) return Promise.resolve(); 174 return saveProfileRow(patch).then(function () {}).catch(function () {}); 175 } 176 177 // Write columns to the profiles row (owner-only per RLS). Mirrors the 178 // values into user_metadata too, so the header avatar works without an 179 // extra fetch and we keep a fallback if the table read ever fails. 180 function saveProfileRow(updates) { 181 var u = getUser(); 182 if (!u || !window.supabaseClientObj) return Promise.reject(new Error('no-session')); 183 return window.supabaseClientObj 184 .from('profiles') 185 .update(updates) 186 .eq('id', u.id) 187 .select('username,display_name,avatar_color,bio,role,created_at') 188 .single() 189 .then(function (res) { 190 if (res && res.error) throw res.error; 191 _profile = res.data; 192 _profileTableReady = true; 193 // Mirror to metadata (best-effort, non-blocking). 194 var mirror = {}; 195 if ('display_name' in updates) mirror[META_NAME] = updates.display_name; 196 if ('avatar_color' in updates) mirror[META_COLOR] = updates.avatar_color;
197 if ('bio' in updates) mirror.lj_bio = updates.bio; 198 if ('username' in updates) mirror.lj_username = updates.username; 199 if (Object.keys(mirror).length) saveProfileMeta(mirror); 200 else { if (typeof window.updateHeaderAuth === 'function') window.updateHeaderAuth(); refreshAvatarVisuals(); } 201 return _profile; 202 }); 203 } 204 205 function usernameTaken(err) { 206 var code = err && (err.code || err.details || err.message || ''); 207 return String(code).indexOf('23505') !== -1 || /duplicate|unique/i.test(String(err && err.message)); 208 } 209 210 // Persist the owner's public-profile summary (trade_count, top_ticker) so 211 // friends can read it via the public_profiles view. Called (debounced) by 212 // friends.js on trade-load. Tolerant: if the columns aren't there yet 213 // (pre-migration 007), the write fails and we silently no-op. 214 function persistProfileSummary(summary) { 215 if (!summary || typeof summary !== 'object') return Promise.resolve(false); 216 var updates = {}; 217 if (typeof summary.trade_count === 'number') updates.trade_count = summary.trade_count; 218 if ('top_ticker' in summary) updates.top_ticker = summary.top_ticker || null; 219 if (!Object.keys(updates).length) return Promise.resolve(false); 220 return saveProfileRow(updates).then(function () { return true; }) 221 .catch(function (e) { 222 console.warn('[account-ui] profile summary not persisted (pre-migration?).', e && e.message ? e.message : e); 223 return false; 224 }); 225 } 226 227 // Live availability check. Resolves {available, reason}. Pre-migration it 228 // resolves available:false reason:'unavailable' so the UI doesn't claim. 229 function checkUsername(name) { 230 name = String(name || '').toLowerCase().trim(); 231 if (!USERNAME_RE.test(name)) return Promise.resolve({ available: false, reason: 'format' }); 232 var u = getUser(); 233 if (!u || !window.supabaseClientObj) return Promise.resolve({ available: false, reason: 'no-session' }); 234 return window.supabaseClientObj 235 .from('public_profiles') 236 .select('id') 237 .ilike('username', name) 238 .maybeSingle() 239 .then(function (res) { 240 if (res && res.error) throw res.error; 241 var row = res ? res.data : null; 242 if (!row || row.id === u.id) return { available: true }; 243 return { available: false, reason: 'taken' }; 244 }) 245 .catch(function (e) { 246 return { available: false, reason: 'error', error: e }; 247 }); 248 } 249 250 // ------------------------------------------------------------------ stats 251 function getQuickStats() { 252 try { 253 if (typeof wjLoadTrades !== 'function' || typeof dashCollectTrades !== 'function') return null; 254 var map = wjLoadTrades(); 255 var all = dashCollectTrades(map) || []; 256 var days = new Set(all.map(function (t) { return t._dateKey; })).size; 257 var agg = (typeof dashComputeAggregate === 'function') ? dashComputeAggregate(all) : null; 258 return { 259 count: all.length, 260 winRate: agg && isFinite(agg.winRate) ? agg.winRate : 0, 261 days: days, 262 net: agg ? agg.net : 0 263 }; 264 } catch (e) { return null; } 265 } 266 function fmtMoney(v) { 267 var a = Math.abs(v || 0); 268 var sign = v > 0 ? '+' : (v < 0 ? '-' : ''); 269 if (a >= 1000) return sign + '$' + (a / 1000).toFixed(1) + 'k'; 270 return sign + '$' + a.toFixed(0); 271 } 272 273 // ----------------------------------------------------------- profile page 274 // Profile is now folded into the Settings modal (Account section). This 275 // stays as an alias so existing callers (header avatar, etc.) still work. 276 // Profile is a full sidebar page (#profile-page) rendered by renderProfile(). 277 // Public hero on top, private settings/billing below. Optional `section` 278 // scrolls to that block (e.g. 'subscription', 'data'). 279 function navigateToProfilePage(section) { 280 if (getUser() && !_profileTableReady) syncProfile(); 281 if (typeof navigateToPage === 'function') navigateToPage('profile-tab', 'profile-page'); 282 renderProfile(); 283 if (section) { 284 var el = document.getElementById('pf-sec-' + section); 285 if (el) setTimeout(function () { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 60); 286 } 287 } 288 // Back-compat alias â the bottom user panel + header avatar call this. 289 function navigateToProfile() { navigateToProfilePage(); } 290 291 // Plan card content, driven by the entitlement layer (access.js). The 292 // trial countdown lives here â a quiet line in Profile â instead of a 293 // nagging top banner during the trial. 294 // P77 â Founder flag, read once per session from the user's own profile 295 // row. Defensive: pre-migration (column missing) the select fails and we
296 // simply never show the badge. `null` = unknown / not loaded yet. 297 var _founder = null; 298 var _founderFetched = false; 299 function loadFounderFlag() { 300 var u = getUser(); 301 if (_founderFetched || !u || !window.supabaseClientObj) return; 302 _founderFetched = true; 303 try { 304 window.supabaseClientObj.from('profiles').select('founder').eq('id', u.id).maybeSingle() 305 .then(function (res) { 306 var v = res && res.data && res.data.founder === true; 307 if (v !== _founder) { 308 _founder = v; 309 try { renderProfile(); } catch (_) {} 310 // Live path: re-render the Subscription section if the 311 // settings modal currently shows it. 312 try { 313 var ov = document.getElementById('lj-settings-overlay'); 314 var active = ov && ov.classList.contains('is-open') && 315 ov.querySelector('.lj-settings-navbtn.is-active[data-section="subscription"]'); 316 if (active) showSettingsSection('subscription'); 317 } catch (_) {} 318 } 319 }) 320 .catch(function () { /* column not migrated yet â badge stays off */ }); 321 } catch (_) {} 322 } 323 324 function pfPlanInfo() { 325 var s = (window.ljPlanState && window.ljPlanState()) || { plan: 'unknown' }; 326 // 'manage' shows the "Manage subscription" link (cancel happens in Whop, 327 // our merchant of record). 'upgrade' shows the upgrade CTA. During the 328 // trial we deliberately DON'T show "Upgrade to Pro": every user starts 329 // on the trial and it converts to Pro automatically when it ends â an 330 // upgrade button there is a confusing no-op that just reopens checkout. 331 // P77 â Founding member (badge) is a PERMANENT status, independent of 332 // the current plan tier: a $24.99 buyer is 'pro', a 100%-promo founder 333 // may sit on 'trial', and a lapsed founder keeps the badge. So compute 334 // the plan card first, then overlay the founder badge whenever the flag 335 // is set â earlier this only fired on plan==='pro', so promo founders 336 // (on 'trial') saw nothing. 337 var info; 338 if (s.plan === 'pro') info = { name: 'Pro', tier: 'Active', note: 'Your subscription is active â thanks for the support.', upgrade: false, manage: true }; 339 else if (s.plan === 'trial') { 340 var d = s.trialDaysLeft || 0; 341 info = { 342 name: 'Pro trial', tier: d + (d === 1 ? ' day left' : ' days left'), 343 note: 'Your trial becomes Pro ($29.99/mo) when it ends â cancel anytime before then and pay nothing.', 344 upgrade: false, manage: true, 345 }; 346 } 347 else if (s.plan === 'expired' || s.plan === 'free') info = { name: 'Trial ended', tier: 'View-only', note: 'Your data is safe and kept for you. The journal is view-only â upgrade anytime to edit again.', upgrade: true, manage: false }; 348 else info = { name: 'Free', tier: 'Current', note: 'Pro launches with the payments rollout.', upgrade: true, manage: false }; 349 350 if (_founder) { 351 // The ð Founder badge carries the status â don't also rename the 352 // tier pill to "Founder" (that double-printed "Founder · Founder"). 353 info.founder = true; 354 if (s.plan === 'pro' || s.plan === 'trial') { 355 info.name = 'Founding Pro'; 356 info.note = 'Founding member â your $24.99/mo price is locked forever. Thank you for backing us early.'; 357 info.upgrade = false;
358 } 359 } 360 return info; 361 } 362 363 function renderProfile() { 364 var root = document.getElementById('profile-page-root'); 365 if (!root) return; 366 var u = getUser(); 367 loadFounderFlag(); // P77 â async; re-renders once when the flag lands 368 var planInfo = pfPlanInfo(); 369 370 if (!u) { 371 root.innerHTML = 372 '<div class="profile-signedout">' + 373 '<h2>You are browsing as a guest</h2>' + 374 '<p>Sign in to manage your profile, sync your journal and unlock your account.</p>' + 375 '<div style="margin-top:18px;"><button class="profile-btn profile-btn--primary" id="profile-signin-btn">Sign in</button></div>' + 376 '</div>'; 377 var sb = document.getElementById('profile-signin-btn'); 378 if (sb) sb.onclick = function () { 379 if (typeof showAuthOverlay === 'function') showAuthOverlay(); 380 }; 381 return; 382 } 383 384 var color = getAvatarColor(); 385 var stats = getQuickStats(); 386 // Only surface the Admin badge â a "Member" tag on every account adds 387 // no information. 388 var roleBadge = isAdmin() ? '<span class="profile-pill profile-pill--admin">Admin</span>' : ''; 389 var bio = getBio(); 390 391 // Public-facing summary â exactly what other traders see (NO $/win-rate). 392 var pubp = (window.LJFriends && window.LJFriends.getSelfPublicProfile) ? window.LJFriends.getSelfPublicProfile() : null; 393 var pubTrades = pubp ? (pubp.trade_count || 0) : ((stats && stats.count) || 0); 394 var pubTicker = (pubp && pubp.top_ticker) ? pubp.top_ticker : 'â'; 395 var tzVal = 'â'; 396 try { 397 if (typeof getResolvedAccountTimezone === 'function') tzVal = getResolvedAccountTimezone() || tzVal; 398 if ((!tzVal || tzVal === 'â') && typeof getBrowserDetectedTimezone === 'function') tzVal = getBrowserDetectedTimezone() || tzVal; 399 } catch (e) {} 400 401 var colorDots = AVATAR_COLORS.map(function (c) { 402 return '<span class="profile-color-dot' + (c.toLowerCase() === color.toLowerCase() ? ' is-active' : '') + 403 '" data-color="' + c + '" style="background:' + c + '" role="button" tabindex="0" aria-label="Avatar color"></span>'; 404 }).join(''); 405 406 var check = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>'; 407 var planFeat = function (t) { return '<div class="pf-plan-feat">' + check + '<span>' + t + '</span></div>'; }; 408 409 var handleHtml = getUsername() 410 ? '<span class="pf-handle">@' + esc(getUsername()) + '</span>' 411 : '<span class="pf-handle pf-handle--muted">no username yet</span>'; 412 413 // Username is claimed once, then permanent (Reddit model) â it's the 414 // user's stable public handle. Display name remains freely editable. 415 var usernameLocked = !!getUsername(); 416 var lockSvg = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>'; 417 var usernameFieldHtml = usernameLocked 418 ? '<div class="profile-field">' + 419 '<label>Username</label>' + 420 '<div class="profile-username-wrap">' + 421 '<span class="profile-username-at">@</span>' + 422 '<input type="text" id="profile-username-input" class="profile-input profile-username-input is-locked" value="' + esc(getUsername()) + '" disabled>' + 423 '<span class="pf-lock" title="Usernames are permanent">' + lockSvg + '</span>' + 424 '</div>' + 425 '<div class="profile-field-hint">Your @handle is permanent â it can\'t be changed.</div>' + 426 '</div>' 427 : '<div class="profile-field">' + 428 '<label for="profile-username-input">Choose your username</label>' + 429 '<div class="profile-username-wrap">' + 430 '<span class="profile-username-at">@</span>' + 431 '<input type="text" id="profile-username-input" class="profile-input profile-username-input" maxlength="20" autocomplete="off" spellcheck="false" placeholder="yourname">' + 432 '</div>' + 433 '<div class="profile-field-hint" id="profile-username-hint">Pick carefully â your handle is permanent. 3â20 chars · aâz, 0â9, _</div>' + 434 '</div>'; 435 436 var founderBadge = planInfo.founder ? ' <span class="pf-founder-badge" title="Founding member â one of the first 100">ð Founder</span>' : ''; 437 438 // A clearly-separated settings section: heading (title + subtitle) sitting 439 // ABOVE its card, with generous spacing between sections (Linear/Vercel style). 440 var sect = function (id, title, sub, bodyInner, extraCardClass) { 441 return '<section class="pf-section">' + 442 '<div class="pf-section-head"><h2 class="pf-section-title">' + title + '</h2>' + 443 (sub ? '<p class="pf-section-sub">' + sub + '</p>' : '') + '</div>' + 444 '<div class="pf-card ' + (extraCardClass || '') + '" id="' + id + '">' + 445 '<div class="pf-card-body">' + bodyInner + '</div>' + 446 '</div>' + 447 '</section>'; 448 }; 449 450 var mainHtml = 451 // ===== PUBLIC HERO â clean identity card (no $/win-rate) ===== 452 '<section class="pf-hero">' + 453 '<div class="pf-hero-row">' + 454 '<div class="pf-hero-avatar" id="profile-hero-avatar" style="background:' + esc(color) + '">' + esc(getInitial()) + '</div>' + 455 '<div class="pf-hero-id">' + 456 '<div class="pf-name-row"><h2 class="pf-name">' + esc(getDisplayName()) + '</h2>' + roleBadge + founderBadge + '</div>' + 457 '<div class="pf-id-sub">' + handleHtml + '<span class="pf-dot">·</span><span class="pf-since">Joined ' + esc(getMemberSince()) + '</span></div>' + 458 // Own email, right in the hero. PRIVATE: the public 459 // profile never carries it (public_profiles has no email 460 // column), hence the explicit "only you" tag â the hero 461 // otherwise mimics what others see. lj-sensitive â the 462 // streamer-mode blur covers it like any other PII. 463 '<div class="pf-id-email"><span class="lj-sensitive">' + esc(getEmail()) + '</span><span class="pf-id-email-tag">visible only to you</span></div>' + 464 (bio ? '<p class="pf-hero-bio" id="profile-hero-bio">' + esc(bio) + '</p>' : '') + 465 '</div>' + 466 '<div class="pf-hero-actions">' + 467 '<button class="profile-btn" id="profile-view-public">View as public</button>' + 468 '<button class="profile-btn profile-btn--primary" id="profile-edit-jump">Edit profile</button>' + 469 '</div>' + 470 '</div>' + 471 '<div class="pf-hero-chips">' + 472 '<span class="pf-chip"><strong>' + esc(String(pubTrades)) + '</strong> trades</span>' + 473 '<span class="pf-chip"><strong>' + esc(pubTicker) + '</strong>
473 top ticker</span>' + 474 '<span class="pf-chip pf-chip--muted">Public profile · your balance & P&L stay private</span>' + 475 '</div>' + 476 '</section>' + 477 478 // ===== single-column, clearly-separated settings sections ===== 479 sect('pf-sec-account', 'Profile', 'Your public identity', 480 usernameFieldHtml + 481 '<div class="profile-field">' + 482 '<label for="profile-name-input">Display name</label>' + 483 '<input type="text" id="profile-name-input" class="profile-input" maxlength="40" placeholder="e.g. Alex" value="' + esc(getDisplayName()) + '">' + 484 '</div>' + 485 '<div class="profile-field">' + 486 '<button type="button" class="profile-collapse-toggle" id="profile-avatar-toggle" aria-expanded="false">' + 487 '<span class="profile-collapse-swatch" id="profile-avatar-swatch" style="background:' + esc(color) + '"></span>' + 488 'Avatar color<span class="profile-collapse-caret">â¾</span>' + 489 '</button>' + 490 '<div class="profile-color-row is-collapsed" id="profile-color-row">' + colorDots + '</div>' + 491 '</div>' + 492 '<div class="profile-form-actions">' + 493 '<button class="profile-btn profile-btn--primary" id="profile-save-btn">Save changes</button>' + 494 '<span class="profile-save-status" id="profile-save-status">Saved</span>' + 495 '</div>' 496 ) + 497 498 sect('pf-sec-subscription', 'Subscription & billing', 'Your plan and payments', 499 '<div class="pf-plan-name">' + esc(planInfo.name) + 500 ' <span class="pf-plan-tier">' + esc(planInfo.tier) + '</span>' + founderBadge + '</div>' + 501 (planInfo.upgrade ? '<button class="profile-btn profile-btn--primary" id="profile-upgrade" style="margin-top:12px;">Upgrade to Pro</button>' : '') + 502 '<div class="pf-plan-feats">' + 503 planFeat('Advanced analytics') + planFeat('Unlimited history') + planFeat('Chart replay & study') + planFeat('Priority support') + 504 '</div>' + 505 '<div class="pf-plan-note" id="profile-upgrade-note">' + esc(planInfo.note) + '</div>' + 506 (planInfo.manage ? '<button class="profile-btn" id="profile-manage-sub" style="margin-top:14px;">Manage subscription</button>' : '') + 507 (!planInfo.founder ? '<button class="profile-btn" id="profile-change-plan" style="margin-top:8px;">Change plan</button>' : '') + 508 '<div id="pf-founding-upsell" style="display:none;margin-top:10px;"></div>', 509 'pf-plan' 510 ) + 511 512 sect('pf-sec-preferences', 'Preferences', 'Timezone & app', 513 pfRow('Timezone', 'Aligns trades and charts to your local session.', 514 '<span class="pf-row-value" style="margin-right:10px;">' + esc(tzVal) + '</span><button class="profile-btn" id="profile-open-tz">Change</button>') + 515 pfRow('Theme', 'A single refined dark theme.', '<span class="pf-row-value">Dark</span>') + 516 pfRow('Product tour', 'Replay the 30-second welcome walkthrough.', '<button class="profile-btn" id="profile-replay-tour">Show tour</button>') 517 ) + 518 519 sect('pf-sec-security', 'Account & security', 'Login & access', 520 pfRow('Email', esc(getEmail()), '') + 521 pfRow('Password', 'Send a reset link to your email.', '<button class="profile-btn" id="profile-reset-pw">Reset</button>') + 522 pfRow('Sign out', 'End your session on this device.', '<button class="profile-btn profile-btn--danger" id="profile-signout">Sign out</button>') 523 ) + 524 525 sect('pf-sec-data', 'Data', 'Your trades on this device', 526 pfRow('Manage data', 'Export or clear the trades stored in this browser.', '<button class="profile-btn" id="profile-open-data">Manage</button>') 527 ) + 528 529 sect('pf-sec-danger', 'Danger zone', 'Irreversible',
530 pfRow('Delete account', 'Permanently erases your trades, profile, and login. Can\'t be undone.', '<button class="profile-btn profile-btn--danger" id="profile-delete-account">Delete</button>'), 531 'pf-card--danger' 532 ); 533 534 // Right rail â a launcher-style friends panel (reuses the friends UI). 535 var railHtml = 536 '<div class="pf-rail-card">' + 537 '<div class="pf-rail-head"><span>Friends</span>' + 538 '<button type="button" class="pf-rail-all" id="profile-friends-all">Open â</button></div>' + 539 '<div id="pf-friends-rail-body" class="pf-rail-body"></div>' + 540 '</div>'; 541 542 root.innerHTML = 543 '<div class="pf-layout">' + 544 '<div class="pf-main">' + mainHtml + '</div>' + 545 '<aside class="pf-rail">' + railHtml + '</aside>' + 546 '</div>'; 547 548 wireProfileEvents(); 549 550 // Fill the rail with the live friends list (search + requests + friends), 551 // reusing the same class-scoped renderer as the page and drawer. 552 if (window.LJFriends && typeof window.LJFriends.renderFriendsInto === 'function') { 553 var railBody = document.getElementById('pf-friends-rail-body'); 554 if (railBody) window.LJFriends.renderFriendsInto(railBody); 555 } 556 var allBtn = document.getElementById('profile-friends-all'); 557 if (allBtn) allBtn.onclick = function () { if (window.navigateToFriends) window.navigateToFriends(); }; 558 } 559 560 // Stylish labelled section divider used to separate the public hero from 561 // the private settings stack on the profile page. 562 function pfDivider(label, hint) { 563 return '<div class="pf-divider"><span class="pf-divider-label">' + label + '</span>' + 564 (hint ? '<span class="pf-divider-hint">' + hint + '</span>' : '') + '</div>'; 565 } 566 // Generic settings row (title + desc on the left, action on the right). 567 function pfRow(title, desc, action) { 568 return '<div class="pf-row"><div><div class="pf-row-title">' + title + '</div>' + 569 '<div class="pf-row-desc">' + desc + '</div></div>' + 570 '<div style="display:flex;align-items:center;flex:0 0 auto;">' + (action || '') + '</div></div>'; 571 } 572 573 function pfStat(k, v, color, bg, iconInner, cls) { 574 return '<div class="pf-stat">' + 575 '<div class="pf-stat-icon" style="background:' + bg + ';color:' + color + '">' + 576 '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + iconInner + '</svg>' + 577 '</div>' + 578 '<div class="pf-stat-text"><div class="pf-stat-k">' + k + '</div>' + 579 '<div class="pf-stat-v ' + (cls || '') + '">' + v + '</div></div>' + 580 '</div>'; 581 } 582 583 function wireProfileEvents() { 584 var openSet = document.getElementById('profile-open-settings'); 585 if (openSet) openSet.onclick = function () { openAppSettings('account'); }; 586 587 // Hero: "View as public" opens the friend-facing profile modal. 588 var viewPublic = document.getElementById('profile-view-public'); 589 if (viewPublic) viewPublic.onclick = function () { 590 var u = getUser(); 591 if (u && typeof window.ljOpenPublicProfile === 'function') window.ljOpenPublicProfile(u.id); 592 }; 593 // Hero: "Edit profile" scrolls down to the editor card. 594 var editJump = document.getElementById('profile-edit-jump'); 595 if (editJump) editJump.onclick = function () { 596 var sec = document.getElementById('pf-sec-account'); 597 if (sec) sec.scrollIntoView({ behavior: 'smooth', block: 'start' }); 598 var ni = document.getElementById('profile-name-input'); 599 if (ni) setTimeout(function () { ni.focus(); }, 350); 600 }; 601 // Preferences: timezone + data managers (reuse the existing modals). 602 var openTz = document.getElementById('profile-open-tz'); 603 if (openTz) openTz.onclick = function () { if (typeof openTimezoneSettingsModal === 'function') openTimezoneSettingsModal(); }; 604 var openData = document.getElementById('profile-open-data'); 605 if (openData) openData.onclick = function () { if (typeof openDataManagementModal === 'function') openDataManagementModal(); }; 606 607 // Replay the onboarding tour on demand (the final tour step promises 608 // "you can replay this tour from your Profile any time"). The tour 609 // engine lives in index.html and exposes window.ljStartTour (forced). 610 var replayTour = document.getElementById('profile-replay-tour'); 611 if (replayTour) replayTour.onclick = function () { 612 if (typeof window.ljStartTour === 'function') { 613 // Send the user to the dashboard first so the steps that spotlight 614 // the Import / Journal / Insights nav tabs land on visible targets. 615 if (typeof navigateToPage === 'function') { 616 try { navigateToPage('dashboard-tab', 'dashboard-page'); } catch (e) {} 617 } 618 window.ljStartTour(); 619 } 620 }; 621 622 // Live avatar preview + selection 623 var selectedColor = getAvatarColor(); 624 var row = document.getElementById('profile-color-row'); 625 var heroAvatar = document.getElementById('profile-hero-avatar'); 626 var nameInput = document.getElementById('profile-name-input'); 627 var swatch = document.getElementById('profile-avatar-swatch'); 628 629 // Collapsible avatar-color section (saves vertical space) 630 var avatarToggle = document.getElementById('profile-avatar-toggle'); 631 if (avatarToggle && row) { 632 avatarToggle.addEventListener('click', function () { 633 var collapsed = row.classList.toggle('is-collapsed'); 634 avatarToggle.setAttribute('aria-expanded', String(!collapsed)); 635 avatarToggle.classList.toggle('is-open', !collapsed); 636 }); 637 } 638 639 if (row) { 640 row.querySelectorAll('.profile-color-dot').forEach(function (dot) { 641 var pick = function () { 642 selectedColor = dot.getAttribute('data-color'); 643 row.querySelectorAll('.profile-color-dot').forEach(function (d) { d.classList.remove('is-active'); }); 644 dot.classList.add('is-active'); 645 if (heroAvatar) heroAvatar.style.background = selectedColor; 646 if (swatch) swatch.style.background = selectedColor; 647 }; 648 dot.addEventListener('click', pick); 649 dot.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } }); 650 }); 651 } 652 653 if (nameInput && heroAvatar) { 654 nameInput.addEventListener('input', function () { 655 var v = nameInput.value.trim(); 656 heroAvatar.textContent = (v && v[0] ? v[0] : 'U').toUpperCase(); 657 var nameEl = document.querySelector('.pf-name'); 658 if (nameEl) nameEl.textContent = v || 'Trader'; 659 }); 660 } 661 662 // --- Username: normalize input + live availability check ----------- 663 var usernameInput = document.getElementById('profile-username-input'); 664 var usernameHint = document.getElementById('profile-username-hint'); 665 var originalUsername = getUsername() || ''; 666 var usernameState = 'idle'; // idle | checking | ok | bad 667 var checkTimer = null; 668 669 var setHint = function (text, cls) { 670 if (!usernameHint) return; 671 usernameHint.textContent = text; 672 usernameHint.className = 'profile-field-hint' + (cls ? ' ' + cls : ''); 673 }; 674 675 if (usernameInput) { 676 usernameInput.addEventListener('input', function () { 677 // force handle charset: lowercase, strip invalid chars 678 var v = usernameInput.value.toLowerCase().replace(/[^a-z0-9_]/g, ''); 679 if (v !== usernameInput.value) usernameInput.value = v; 680 if (checkTimer) clearTimeout(checkTimer); 681 682 if (v === '' || v === originalUsername) { usernameState = 'idle'; setHint('3â20 characters · lowercase letters, numbers, underscore'); return; } 683 if (!USERNAME_RE.test(v)) { usernameState = 'bad'; setHint('Must be 3â20 chars: aâz, 0â9, _', 'is-bad'); return; } 684 usernameState = 'checking'; setHint('Checking availabilityâ¦'); 685 checkTimer = setTimeout(function () { 686 checkUsername(v).then(function (r) { 687 if (usernameInput.value.toLowerCase() !== v) return; // changed since 688 if (r.available) { usernameState = 'ok'; setHint('@' + v + ' is available', 'is-ok'); } 689 else if (r.reason === 'taken') { usernameState = 'bad'; setHint('@' + v + ' is taken', 'is-bad'); } 690 else if (r.reason === 'error') { usernameState = 'idle'; setHint('Run the DB migration to enable usernames', 'is-bad'); } 691 else { usernameState = 'bad'; setHint('Invalid username', 'is-bad'); } 692 }); 693 }, 400); 694 }); 695 } 696 697 var saveBtn = document.getElementById('profile-save-btn'); 698 if (saveBtn) saveBtn.onclick = function () { 699 var name = (nameInput ? nameInput.value.trim() : ''); 700 var uname = (usernameInput ? usernameInput.value.toLowerCase().trim() : ''); 701 // Username is permanent: only claimable when none is set yet. 702 var claimingUsername = !originalUsername && !!uname; 703 704 if (claimingUsername) { 705 if (!USERNAME_RE.test(uname)) { setHint('Fix the username before saving (3â20 chars: aâz, 0â9, _)', 'is-bad'); if (usernameInput) usernameInput.focus(); return; } 706 if (usernameState === 'bad') { if (usernameInput) usernameInput.focus(); return; } 707 } 708 709 saveBtn.disabled = true; 710 saveBtn.textContent = 'Savingâ¦'; 711 var done = function (ok, takenMsg) { 712 saveBtn.disabled = false;
713 saveBtn.textContent = 'Save changes'; 714 if (!ok && takenMsg) { setHint(takenMsg, 'is-bad'); if (usernameInput) usernameInput.focus(); return; } 715 var status = document.getElementById('profile-save-status'); 716 if (status) { status.classList.add('is-visible'); setTimeout(function () { status.classList.remove('is-visible'); }, 1800); } 717 // If a username was just claimed, re-render so the field locks. 718 if (claimingUsername && getUsername()) { renderProfile(); return; } 719 }; 720 721 if (_profileTableReady) { 722 var updates = { display_name: name, avatar_color: selectedColor }; 723 if (claimingUsername) updates.username = uname; 724 saveProfileRow(updates) 725 .then(function () { done(true); }) 726 .catch(function (e) { 727 if (usernameTaken(e)) { done(false, '@' + uname + ' is taken'); } 728 else { console.warn('[account-ui] save failed', e); done(true); } 729 }); 730 } else { 731 // Pre-migration fallback: store in metadata (no uniqueness). 732 saveProfileMeta({ lj_display_name: name, lj_avatar_color: selectedColor, lj_username: claimingUsername ? uname : undefined }).then(function () { done(true); }); 733 } 734 }; 735 736 var resetBtn = document.getElementById('profile-reset-pw'); 737 if (resetBtn) resetBtn.onclick = sendPasswordReset; 738 739 var signout = document.getElementById('profile-signout'); 740 if (signout) signout.onclick = function () { 741 if (typeof window.handleLogout === 'function') window.handleLogout(); 742 }; 743 744 var upgrade = document.getElementById('profile-upgrade'); 745 if (upgrade) upgrade.onclick = function () { 746 // P77 â open the paywall (all live terms incl. Founding) instead 747 // of firing a blind monthly checkout. 748 if (typeof window.ljOpenPaywall === 'function') { window.ljOpenPaywall('__switch'); return; } 749 if (typeof window.ljStartCheckout === 'function') { window.ljStartCheckout(); return; } 750 var note = document.getElementById('profile-upgrade-note'); 751 if (note) note.textContent = 'ð Pro is launching soon'; 752 }; 753 754 var manage = document.getElementById('profile-manage-sub'); 755 if (manage) manage.onclick = function () { if (typeof window.ljManageSubscription === 'function') window.ljManageSubscription(); }; 756 757 // P77 â Change plan + Founding upsell row. 758 var changePlan = document.getElementById('profile-change-plan'); 759 if (changePlan) changePlan.onclick = function () { 760 if (typeof window.ljOpenPaywall === 'function') window.ljOpenPaywall('__switch'); 761 }; 762 (function () { 763 var slot = document.getElementById('pf-founding-upsell'); 764 if (!slot || typeof window.ljFoundingStatus !== 'function') return; 765 var s = (window.ljPlanState && window.ljPlanState()) || {}; 766 // Upsell only makes sense for users already paying/trialing the 767 // regular plans; expired users get it inside the paywall instead. 768 if (!(s.plan === 'pro' || s.plan === 'trial') || _founder) return; 769 window.ljFoundingStatus().then(function (st) { 770 if (!st || !st.enabled) return; 771 var leftTxt = (typeof st.left === 'number') ? (st.left + ' of ' + st.total + ' seats left') : ('first ' + st.total + ' only'); 772 slot.removeAttribute('style'); // drop the hidden/margin inline; .pf-found-up owns layout 773 slot.className = 'pf-found-up'; 774 slot.innerHTML = 775 '<button class="pf-found-up-cta" id="pf-founding-go"><span class="pf-found-up-star">ð </span> Switch to Founding â $24.99/mo forever</button>' + 776 '<div class="pf-found-up-desc"><strong>' + leftTxt + '</strong> · price locked for life · permanent Founder badge. After switching, cancel your old plan in Manage subscription if it doesnât end automatically.</div>'; 777 var go = document.getElementById('pf-founding-go'); 778 if (go) go.onclick = function () { 779 if (typeof window.ljStartCheckout === 'function') window.ljStartCheckout('founding'); 780 }; 781 }).catch(function () {}); 782 })(); 783 784 var delBtn = document.getElementById('profile-delete-account'); 785 if (delBtn) delBtn.onclick = openDeleteAccountModal; 786 } 787
788 /* P75.17 â Account deletion confirm modal + API call. 789 Guards: 790 - Requires typing "DELETE" exactly (case-sensitive) â prevents accidental clicks. 791 - Forwards the Supabase access token so the server can verify identity. 792 - On 200, clears localStorage, signs out, redirects to landing. */ 793 function openDeleteAccountModal() { 794 var u = getUser(); 795 if (!u) return; 796 var existing = document.getElementById('lj-delete-modal'); 797 if (existing) existing.remove(); 798 var modal = document.createElement('div'); 799 modal.id = 'lj-delete-modal'; 800 modal.className = 'lj-delete-modal'; 801 modal.innerHTML = 802 '<div class="lj-delete-modal__backdrop"></div>' + 803 '<div class="lj-delete-modal__card" role="dialog" aria-labelledby="lj-delete-title">' + 804 '<h3 id="lj-delete-title">Delete account</h3>' + 805 '<p class="lj-delete-modal__warning">' + 806 'This is permanent. We\'ll erase:' + 807 '<ul>' + 808 '<li>All your logged trades</li>' + 809 '<li>Your profile + username</li>' + 810 '<li>Your login (Supabase auth)</li>' + 811 '<li>Your Whop subscription is cancelled automatically</li>' + 812 '</ul>' + 813 '</p>' + 814 '<p class="lj-delete-modal__confirm">Type <code>DELETE</code> to confirm.</p>' + 815 '<input type="text" id="lj-delete-input" class="lj-delete-modal__input" autocomplete="off" spellcheck="false" placeholder="DELETE">' + 816 '<div class="lj-delete-modal__actions">' + 817 '<button type="button" class="profile-btn" id="lj-delete-cancel">Cancel</button>' + 818 '<button type="button" class="profile-btn profile-btn--danger" id="lj-delete-confirm" disabled>Delete forever</button>' + 819 '</div>' + 820 '<div class="lj-delete-modal__status" id="lj-delete-status" aria-live="polite"></div>' + 821 '</div>'; 822 document.body.appendChild(modal); 823 var input = document.getElementById('lj-delete-input'); 824 var confirmBtn = document.getElementById('lj-delete-confirm'); 825 var cancelBtn = document.getElementById('lj-delete-cancel'); 826 var status = document.getElementById('lj-delete-status'); 827 input.addEventListener('input', function () { 828 confirmBtn.disabled = input.value !== 'DELETE'; 829 }); 830 var close = function () { modal.remove(); }; 831 cancelBtn.addEventListener('click', close); 832 modal.querySelector('.lj-delete-modal__backdrop').addEventListener('click', close); 833 confirmBtn.addEventListener('click', function () { 834 confirmBtn.disabled = true; 835 cancelBtn.disabled = true; 836 status.textContent = 'Deletingâ¦'; 837 status.className = 'lj-delete-modal__status'; 838 deleteAccountRequest() 839 .then(function (result) { 840 result = result || {}; 841 // If we had a subscription but couldn't auto-cancel it on Whop, 842 // send the user to Whop to stop it manually (rare fallback). 843 var needsManualCancel = result.hadMembership && !result.whopCancelled; 844 status.textContent = needsManualCancel 845 ? 'Account deleted. Opening Whop to cancel your subscriptionâ¦' 846 : 'Account deleted. Redirectingâ¦'; 847 status.className = 'lj-delete-modal__status is-ok'; 848 try { localStorage.removeItem('trades'); localStorage.removeItem('lj_profile_parked_trades_v1'); } catch (e) {} 849 if (needsManualCancel) { 850 try { window.open('https://whop.com/orders', '_blank', 'noopener,noreferrer'); } catch (e) {} 851 } 852 // Fully clear the client session BEFORE redirecting, otherwise 853 // the deleted user's still-valid JWT lingers â returning to /app 854 // re-hydrates a dead session â infinite "Loading your accountâ¦". 855 var go = function () { window.location.href = '/landing.html'; }; 856 var auth = window.supabaseClientObj && window.supabaseClientObj.auth; 857 // Belt-and-braces: nuke any Supabase auth token from storage too 858 // (a deleted-user signOut can error before clearing it). 859 try { 860 Object.keys(localStorage).forEach(function (k) { 861 if (/^sb-.*-auth-token$/.test(k) || k === 'supabase.auth.token') localStorage.removeItem(k); 862 }); 863 } catch (e) {} 864 try { window.currentUser = null; } catch (e) {} 865 if (auth && auth.signOut) { 866 // local scope: the server account is already gone, so a global 867 // signout would error â we only need to clear the client. 868 Promise.resolve(auth.signOut({ scope: 'local' })).then(go, go); 869 setTimeout(go, 1500); // safety: never hang on signOut 870 } else { go(); } 871 }) 872 .catch(function (err) { 873 status.textContent = 'Failed: ' + (err && err.message ? err.message : 'unknown error'); 874 status.className = 'lj-delete-modal__status is-err'; 875 confirmBtn.disabled = false;
876 cancelBtn.disabled = false; 877 }); 878 }); 879 setTimeout(function () { input.focus(); }, 60); 880 } 881 882 function deleteAccountRequest() { 883 var s = window.supabaseClientObj && window.supabaseClientObj.auth ? window.supabaseClientObj.auth : null; 884 var sessionP = s ? s.getSession() : Promise.resolve({ data: { session: null } }); 885 return sessionP.then(function (res) { 886 var token = res && res.data && res.data.session && res.data.session.access_token; 887 if (!token) throw new Error('not signed in'); 888 // Same-origin in prod (''), local dev proxy on :3003. The old hard- 889 // coded 127.0.0.1:3003 made this fail with "Failed to fetch" on the 890 // live site (the browser tried to reach the user's own localhost). 891 var apiBase = /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname) 892 ? ('http://' + window.location.hostname + ':3003') : ''; 893 return fetch(apiBase + '/api/account/delete', { 894 method: 'POST', 895 headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, 896 }); 897 }).then(function (r) { 898 if (r.ok) return r.json(); 899 return r.json().catch(function () { return {}; }).then(function (j) { 900 throw new Error(j.error || ('HTTP ' + r.status)); 901 }); 902 }); 903 } 904 905 function sendPasswordReset() { 906 var u = getUser(); 907 if (!u || !u.email || !window.supabaseClientObj) { 908 alert('No email on file for this account.'); 909 return; 910 } 911 var btns = document.querySelectorAll('#profile-reset-pw, #set-reset-pw'); 912 btns.forEach(function (b) { b.disabled = true; b.textContent = 'Sendingâ¦'; }); 913 window.supabaseClientObj.auth.resetPasswordForEmail(u.email, { redirectTo: window.location.origin + '/app' }) 914 .then(function (res) { 915 if (res && res.error) throw res.error; 916 btns.forEach(function (b) { b.disabled = false; b.textContent = 'Email sent â'; }); 917 setTimeout(function () { btns.forEach(function (b) { b.textContent = 'Reset password'; }); }, 2500); 918 }) 919 .catch(function (e) { 920 console.warn('[account-ui] password reset failed', e); 921 btns.forEach(function (b) { b.disabled = false; b.textContent = 'Reset password'; }); 922 alert('Could not send reset email: ' + (e && e.message ? e.message : 'unknown error')); 923 }); 924 } 925 926 // Keep the header avatar (and any rendered profile avatar) in sync with 927 // the chosen display name + color. Wraps the existing updateHeaderAuth so 928 // we don't have to touch script.js. 929 function refreshAvatarVisuals() { 930 var av = document.querySelector('.user-avatar'); 931 if (av && getUser()) { 932 av.textContent = getInitial(); 933 av.style.background = getAvatarColor(); 934 av.style.color = '#141414'; 935 } 936 var nameChip = document.getElementById('header-user-name'); 937 if (nameChip) nameChip.textContent = getUser() ? getDisplayName() : 'Guest'; 938 } 939 940 function installHeaderHook() { 941 if (typeof window.updateHeaderAuth === 'function' && !window.updateHeaderAuth.__ljWrapped) { 942 var orig = window.updateHeaderAuth; 943 var wrapped = function () { 944 var r = orig.apply(this, arguments); 945 try { refreshAvatarVisuals(); makeAvatarClickable(); } catch (e) {} 946 return r; 947 }; 948 wrapped.__ljWrapped = true; 949 window.updateHeaderAuth = wrapped; 950 } 951 refreshAvatarVisuals(); 952 makeAvatarClickable(); 953 } 954 955 function makeAvatarClickable() { 956 var nav = document.querySelector('.nav-user'); 957 if (nav && !nav.__ljClickable) { 958 nav.__ljClickable = true; 959 nav.style.cursor = 'pointer'; 960 nav.title = 'Open profile'; 961 nav.addEventListener('click', function (e) { 962 // Don't hijack the ADMIN button. 963 if (e.target.closest('#admin-dashboard-btn')) return; 964 navigateToProfile(); 965 }); 966 } 967 } 968 969 // ---------------------------------------------------------- settings modal 970 var SETTINGS_SECTIONS = [ 971 { id: 'account', label: 'Account', icon: '<circle cx="12" cy="8" r="4"></circle><path d="M4 21v-1a6 6 0 0 1 12 0v1"></path>' }, 972 { id: 'subscription', label: 'Subscription', icon: '<rect x="2" y="5" width="20" height="14" rx="2"></rect><line x1="2" y1="10" x2="22" y2="10"></line>' }, 973 { id: 'preferences', label: 'Preferences', icon: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>' }, 974 { id: 'data', label: 'Data', icon: '<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>' }, 975 { id: 'about', label: 'About', icon: '<circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line>' } 976 ]; 977 978 function ensureSettingsModal() { 979 var existing = document.getElementById('lj-settings-overlay'); 980 if (existing) return existing; 981
982 var overlay = document.createElement('div'); 983 overlay.className = 'lj-settings-overlay'; 984 overlay.id = 'lj-settings-overlay'; 985 986 var navBtns = SETTINGS_SECTIONS.map(function (s) { 987 return '<button class="lj-settings-navbtn" data-section="' + s.id + '">' + 988 '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + s.icon + '</svg>' + 989 '<span>' + s.label + '</span></button>'; 990 }).join(''); 991 992 overlay.innerHTML = 993 '<div class="lj-settings-modal" role="dialog" aria-modal="true" aria-label="Settings">' + 994 '<aside class="lj-settings-aside">' + 995 '<div class="lj-settings-aside-title">Settings</div>' + 996 navBtns + 997 '<div class="lj-settings-aside-foot">LiJournal · Beta</div>' + 998 '</aside>' + 999 '<div class="lj-settings-main">' + 1000 '<div class="lj-settings-head">' + 1001 '<h3 id="lj-settings-title">Account</h3>' + 1002 '<button class="lj-settings-close" id="lj-settings-close" aria-label="Close settings">' + 1003 '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>' + 1004 '</button>' + 1005 '</div>' + 1006 '<div class="lj-settings-body" id="lj-settings-body"></div>' + 1007 '</div>' + 1008 '</div>'; 1009 1010 document.body.appendChild(overlay); 1011 1012 overlay.addEventListener('click', function (e) { 1013 if (e.target === overlay) closeAppSettings(); 1014 }); 1015 overlay.querySelector('#lj-settings-close').onclick = closeAppSettings; 1016 overlay.querySelectorAll('.lj-settings-navbtn').forEach(function (btn) { 1017 btn.onclick = function () { showSettingsSection(btn.getAttribute('data-section')); }; 1018 }); 1019 document.addEventListener('keydown', function (e) { 1020 if (e.key === 'Escape' && overlay.classList.contains('is-open')) closeAppSettings(); 1021 }); 1022 1023 return overlay; 1024 } 1025 1026 // Settings now live on the full Profile page (no more modal). The gear icon 1027 // and every openAppSettings(section) caller route here and scroll to the 1028 // matching block. Section ids map: accountâeditor, subscriptionâbilling, 1029 // preferences, data. The old modal builder stays defined but unused. 1030 function openAppSettings(section) { 1031 var dd = document.getElementById('settings-dropdown'); 1032 if (dd) dd.classList.remove('active'); 1033 navigateToProfilePage(section || 'account'); 1034 } 1035 1036 function closeAppSettings() { 1037 var overlay = document.getElementById('lj-settings-overlay'); 1038 if (overlay) overlay.classList.remove('is-open'); 1039 } 1040 1041 function showSettingsSection(id) { 1042 var overlay = ensureSettingsModal(); 1043 var sec = SETTINGS_SECTIONS.find(function (s) { return s.id === id; }) || SETTINGS_SECTIONS[0]; 1044 overlay.querySelectorAll('.lj-settings-navbtn').forEach(function (b) { 1045 b.classList.toggle('is-active', b.getAttribute('data-section') === sec.id); 1046 }); 1047 var title = document.getElementById('lj-settings-title'); 1048 if (title) title.textContent = sec.label; 1049 var body = document.getElementById('lj-settings-body'); 1050 if (body) { 1051 body.innerHTML = renderSettingsSection(sec.id); 1052 wireSettingsSection(sec.id); 1053 } 1054 } 1055
1056 function renderSettingsSection(id) { 1057 var u = getUser(); 1058 if (id === 'account') { 1059 if (!u) { 1060 return '<p style="color:var(--text-secondary)">You are not signed in.</p>' + 1061 '<div style="margin-top:14px;"><button class="profile-btn profile-btn--primary" id="set-signin">Sign in</button></div>'; 1062 } 1063 var color = getAvatarColor(); 1064 var colorDots = AVATAR_COLORS.map(function (c) { 1065 return '<span class="profile-color-dot' + (c.toLowerCase() === color.toLowerCase() ? ' is-active' : '') + 1066 '" data-color="' + c + '" style="background:' + c + '" role="button" tabindex="0" aria-label="Avatar color"></span>'; 1067 }).join(''); 1068 var locked = !!getUsername(); 1069 var lockSvg = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>'; 1070 var usernameFieldHtml = locked 1071 ? '<div class="profile-field"><label>Username</label><div class="profile-username-wrap">' + 1072 '<span class="profile-username-at">@</span>' + 1073 '<input type="text" id="set-username-input" class="profile-input profile-username-input is-locked" value="' + esc(getUsername()) + '" disabled>' + 1074 '<span class="pf-lock" title="Usernames are permanent">' + lockSvg + '</span></div>' + 1075 '<div class="profile-field-hint">Your @handle is permanent â it can\'t be changed.</div></div>' 1076 : '<div class="profile-field"><label for="set-username-input">Choose your username</label><div class="profile-username-wrap">' + 1077 '<span class="profile-username-at">@</span>' + 1078 '<input type="text" id="set-username-input" class="profile-input profile-username-input" maxlength="20" autocomplete="off" spellcheck="false" placeholder="yourname"></div>' + 1079 '<div class="profile-field-hint" id="set-username-hint">Pick carefully â your handle is permanent. 3â20 chars · aâz, 0â9, _</div></div>'; 1080 return '' + 1081 '<div class="lj-set-mini-profile">' + 1082 '<div class="lj-set-mini-avatar" id="set-avatar-preview" style="background:' + esc(color) + '">' + esc(getInitial()) + '</div>' + 1083 '<div><div class="lj-set-mini-name" id="set-name-preview">' + esc(getDisplayName()) + '</div>' + 1084 '<div class="lj-set-mini-email">' + esc(getEmail()) + '</div></div>' + 1085 '</div>' + 1086 '<div class="lj-set-editor">' + 1087 usernameFieldHtml + 1088 '<div class="profile-field"><label for="set-name-input">Display name</label>' + 1089 '<input type="text" id="set-name-input" class="profile-input" maxlength="40" placeholder="e.g. Alex" value="' + esc(getDisplayName()) + '"></div>' + 1090 '<div class="profile-field"><label>Avatar color</label><div class="profile-color-row" id="set-color-row">' + colorDots + '</div></div>' + 1091 '<div class="profile-form-actions">' + 1092 '<button class="profile-btn profile-btn--primary" id="set-save-identity">Save changes</button>' + 1093 '<span class="profile-save-status" id="set-save-status">Saved</span>' + 1094 '</div>' + 1095 '</div>' + 1096 row('Email', esc(getEmail()), '') + 1097 row('Password', 'Send a reset link to your email.', '<button class="profile-btn" id="set-reset-pw">Reset password</button>') + 1098 row('Sign out', 'End your session on this device.', '<button class="profile-btn profile-btn--danger" id="set-signout">Sign out</button>') + 1099 '<div class="lj-set-danger">' + 1100 row('Delete account', 'Permanently erases your trades, profile and login. Can\'t be undone.', '<button class="profile-btn profile-btn--danger" id="set-delete-account">Delete</button>') + 1101 '</div>'; 1102 } 1103 if (id === 'subscription') { 1104 if (!u) return '<p style="color:var(--text-secondary)">Sign in to see your plan.</p>'; 1105 var pi = pfPlanInfo(); 1106 var check = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>'; 1107 var feat = function (t) { return '<div class="pf-plan-feat">' + check + '<span>' + t + '</span></div>'; }; 1108 return '' + 1109 '<section class="pf-card pf-plan" style="margin:0;">
1109' + 1110 '<div class="pf-plan-top">' + 1111 '<div class="pf-plan-eyebrow">Your plan</div>' + 1112 '<div class="pf-plan-name">' + esc(pi.name) + 1113 ' <span class="pf-plan-tier">' + esc(pi.tier) + '</span>' + 1114 (pi.founder ? ' <span class="pf-founder-badge" title="Founding member â one of the first 100">ð Founder</span>' : '') + 1115 '</div>' + 1116 (pi.upgrade ? '<button class="profile-btn profile-btn--primary" id="set-upgrade">Upgrade to Pro</button>' : '') + 1117 '</div>' + 1118 '<div class="pf-plan-feats">' + feat('Advanced analytics') + feat('Unlimited history') + feat('Chart replay & study') + feat('Priority support') + '</div>' + 1119 '<div class="pf-plan-note">' + esc(pi.note) + '</div>' + 1120 (pi.manage ? '<button class="profile-btn" id="set-manage-sub" style="margin-top:14px;">Manage subscription</button>' : '') + 1121 // P77 â plan switching (hidden for founders: they already 1122 // hold the best deal). Founding upsell row fills async 1123 // from /api/founding/status in wireSettingsSection. 1124 (!pi.founder ? '<button class="profile-btn" id="set-change-plan" style="margin-top:8px;">Change plan</button>' : '') + 1125 '<div id="set-founding-upsell" style="display:none;margin-top:10px;"></div>' + 1126 '</section>'; 1127 } 1128 if (id === 'preferences') { 1129 var tz = 'â'; 1130 try { 1131 if (typeof getResolvedAccountTimezone === 'function') tz = getResolvedAccountTimezone() || tz; 1132 if ((!tz || tz === 'â') && typeof getBrowserDetectedTimezone === 'function') tz = getBrowserDetectedTimezone() || tz; 1133 } catch (e) {} 1134 return '' + 1135 row('Timezone', 'Used to align trades and charts to your local session.', 1136 '<button class="profile-btn" id="set-open-tz">Change</button>', 1137 '<span class="lj-set-row-value" style="margin-right:12px;">' + esc(tz) + '</span>') + 1138 row('Theme', 'LiJournal currently ships a single refined dark theme.', 1139 '<span class="lj-set-row-value">Dark</span>') + 1140 row('Product tour', 'Replay the 30-second welcome walkthrough.', 1141 '<button class="profile-btn" id="set-replay-tour">Show tour</button>'); 1142 } 1143 if (id === 'data') { 1144 return '' + 1145 row('Manage data', 'Export or clear the trades stored in this browser.', 1146 '<button class="profile-btn" id="set-open-data">Manage</button>') + 1147 row('Clear local data', 'Remove cached trades from this device (cloud copy is kept).', 1148 '<button class="profile-btn profile-btn--danger" id="set-clear-data">Clear</button>'); 1149 } 1150 // about 1151 return '' + 1152 '<div class="lj-settings-about">' + 1153 '<div class="lj-settings-about-logo">LiJournal</div>' + 1154 '<p>A focused trading journal â log, review and sharpen your edge.</p>' + 1155 '<p style="margin-top:10px; font-size:0.8rem; color:var(--text-tertiary);">Beta · Built for serious traders</p>' + 1156 '</div>'; 1157 } 1158 1159 function row(title, desc, action, extra) { 1160 return '<div class="lj-set-row">' + 1161 '<div class="lj-set-row-text"><div class="lj-set-row-title">' + title + '</div>' + 1162 '<div class="lj-set-row-desc">' + desc + '</div></div>' + 1163 '<div style="display:flex;align-items:center;flex:0 0 auto;">' + (extra || '') + (action || '') + '</div>' + 1164 '</div>'; 1165 } 1166 1167 function wireSettingsSection(id) { 1168 var on = function (sel, fn) { var el = document.getElementById(sel); if (el) el.onclick = fn; }; 1169 if (id === 'account') { 1170 on('set-signin', function () { closeAppSettings(); if (typeof showAuthOverlay === 'function') showAuthOverlay(); }); 1171 on('set-reset-pw', sendPasswordReset); 1172 on('set-signout', function () { closeAppSettings(); if (typeof window.handleLogout === 'function') window.handleLogout(); }); 1173 on('set-delete-account', openDeleteAccountModal); 1174 1175 // --- identity editor (moved here from the old Profile page) ------- 1176 var selectedColor = getAvatarColor(); 1177 var miniAvatar = document.getElementById('set-avatar-preview'); 1178 var namePreview = document.getElementById('set-name-preview'); 1179 var nameInput = document.getElementById('set-name-input'); 1180 var colorRow = document.getElementById('set-color-row'); 1181 var usernameInput = document.getElementById('set-username-input'); 1182 var usernameHint = document.getElementById('set-username-hint'); 1183 var originalUsername = getUsername() || ''; 1184 var usernameState = 'idle'; 1185 var checkTimer = null; 1186 var setHint = function (text, cls) { if (!usernameHint) return; usernameHint.textContent = text; usernameHint.className = 'profile-field-hint' + (cls ? ' ' + cls : ''); }; 1187 1188 if (colorRow) { 1189 colorRow.querySelectorAll('.profile-color-dot').forEach(function (dot) { 1190 var pick = function () { 1191 selectedColor = dot.getAttribute('data-color'); 1192 colorRow.querySelectorAll('.profile-color-dot').forEach(function (d) { d.classList.remove('is-active'); }); 1193 dot.classList.add('is-active'); 1194 if (miniAvatar) miniAvatar.style.background = selectedColor; 1195 }; 1196 dot.addEventListener('click', pick); 1197 dot.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } }); 1198 }); 1199 } 1200 if (nameInput) { 1201 nameInput.addEventListener('input', function () { 1202 var v = nameInput.value.trim(); 1203 if (namePreview) namePreview.textContent = v || 'Trader'; 1204 if (miniAvatar) miniAvatar.textContent = (v && v[0] ? v[0] : 'U').toUpperCase(); 1205 }); 1206 } 1207 if (usernameInput && !originalUsername) { 1208 usernameInput.addEventListener('input', function () { 1209 var v = usernameInput.value.toLowerCase().replace(/[^a-z0-9_]/g, ''); 1210 if (v !== usernameInput.value) usernameInput.value = v; 1211 if (checkTimer) clearTimeout(checkTimer); 1212 if (v === '') { usernameState = 'idle'; setHint('3â20 characters · lowercase letters, numbers, underscore'); return; } 1213 if (!USERNAME_RE.test(v)) { usernameState = 'bad'; setHint('Must be 3â20 chars: aâz, 0â9, _', 'is-bad'); return; } 1214 usernameState = 'checking'; setHint('Checking availabilityâ¦'); 1215 checkTimer = setTimeout(function () { 1216 checkUsername(v).then(function (r) { 1217 if (usernameInput.value.toLowerCase() !== v) return; 1218 if (r.available) { usernameState = 'ok'; setHint('@' + v + ' is available', 'is-ok'); } 1219 else if (r.reason === 'taken') { usernameState = 'bad'; setHint('@' + v + ' is taken', 'is-bad'); } 1220 else if (r.reason === 'error') { usernameState = 'idle'; setHint('Run the DB migration to enable usernames', 'is-bad'); } 1221 else { usernameState = 'bad'; setHint('Invalid username', 'is-bad'); } 1222 }); 1223 }, 400); 1224 }); 1225 } 1226 on('set-save-identity', function () { 1227 var saveBtn = document.getElementById('set-save-identity'); 1228 var name = (nameInput ? nameInput.value.trim() : ''); 1229 var uname = (usernameInput ? usernameInput.value.toLowerCase().trim() : ''); 1230 var claiming = !originalUsername && !!uname; 1231 if (claiming) { 1232 if (!USERNAME_RE.test(uname)) { setHint('Fix the username before saving (3â20 chars: aâz, 0â9, _)', 'is-bad'); if (usernameInput) usernameInput.focus(); return; } 1233 if (usernameState === 'bad') { if (usernameInput) usernameInput.focus(); return; } 1234 } 1235 saveBtn.disabled = true; saveBtn.textContent = 'Savingâ¦'; 1236 var done = function (ok, takenMsg) { 1237 saveBtn.disabled = false; saveBtn.textContent = 'Save changes'; 1238 if (!ok && takenMsg) { setHint(takenMsg, 'is-bad'); if (usernameInput) usernameInput.focus(); return; } 1239 var status = document.getElementById('set-save-status'); 1240 if (status) { status.classList.add('is-visible'); setTimeout(function () { status.classList.remove('is-visible'); }, 1800); } 1241 if (typeof window.updateHeaderAuth === 'function') window.updateHeaderAuth(); 1242 if (typeof refreshAvatarVisuals === 'function') refreshAvatarVisuals(); 1243 if (claiming && getUsername()) { showSettingsSection('account'); } 1244 }; 1245 if (_profileTableReady) { 1246 var updates = { display_name: name, avatar_color: selectedColor }; 1247 if (claiming) updates.username = uname; 1248 saveProfileRow(updates).then(function () { done(true); }) 1249 .catch(function (e) { if (usernameTaken(e)) { done(false, '@' + uname + ' is taken'); } else { console.warn('[account-ui] save failed', e); done(true); } }); 1250 } else { 1251 saveProfileMeta({ lj_display_name: name, lj_avatar_color: selectedColor, lj_username: claiming ? uname : undefined }).then(function () { done(true); }); 1252 } 1253 }); 1254 } else if (id === 'subscription') { 1255 loadFounderFlag(); // P77 â badge/upsell depend on it; re-renders on arrival 1256 on('set-upgrade', function () { 1257 // P77 â pick a term (incl. Founding) instead of blind monthly. 1258 if (typeof window.ljOpenPaywall === 'function') { closeAppSettings(); window.ljOpenPaywall('__switch'); return; } 1259 if (typeof window.ljStartCheckout === 'function') window.ljStartCheckout(); 1260 }); 1261 on('set-manage-sub', function () { 1262 if (typeof window.ljManageSubscription === 'function') window.ljManageSubscription(); 1263 }); 1264 on('set-change-plan', function () { 1265 if (typeof window.ljOpenPaywall === 'function') { closeAppSettings(); window.ljOpenPaywall('__switch'); } 1266 }); 1267 // P77 â Founding upsell for current trial/pro users while seats last. 1268 (function () { 1269 var slot = document.getElementById('set-founding-upsell'); 1270 if (!slot || typeof window.ljFoundingStatus !== 'function') return; 1271 var s = (window.ljPlanState && window.ljPlanState()) || {}; 1272 if (!(s.plan === 'pro' || s.plan === 'trial') || _founder) return; 1273 window.ljFoundingStatus().then(function (st) { 1274 if (!st || !st.enabled) return; 1275 var leftTxt = (typeof st.left === 'number') ? (st.left + ' of ' + st.total + ' seats left') : ('first ' + st.total + ' only'); 1276 slot.removeAttribute('style'); // drop the hidden/margin inline; .pf-found-up owns layout 1277 slot.className = 'pf-found-up'; 1278 slot.innerHTML = 1279 '<button class="pf-found-up-cta" id="set-founding-go"><span class="pf-found-up-star">ð </span> Switch to Founding â $24.99/mo forever</button>' + 1280 '<div class="pf-found-up-desc"><strong>' + leftTxt + '</strong> · price locked for life · permanent Founder badge. After switching, cancel the old plan in Manage subscription if it doesnât end automatically.</div>'; 1281 var go = document.getElementById('set-founding-go'); 1282 if (go) go.onclick = function () { 1283 if (typeof window.ljStartCheckout === 'function') window.ljStartCheckout('founding'); 1284 }; 1285 }).catch(function () {}); 1286 })(); 1287 } else if (id === 'preferences') { 1288 on('set-open-tz', function () { closeAppSettings(); if (typeof openTimezoneSettingsModal === 'function') openTimezoneSettingsModal(); }); 1289 on('set-replay-tour', function () { 1290 closeAppSettings(); 1291 if (typeof navigateToPage === 'function') { try { navigateToPage('dashboard-tab', 'dashboard-page'); } catch (e) {} } 1292 if (typeof window.ljStartTour === 'function') window.ljStartTour(); 1293 }); 1294 } else if (id === 'data') { 1295 on('set-open-data', function () { closeAppSettings(); if (typeof openDataManagementModal === 'function') openDataManagementModal(); }); 1296 on('set-clear-data', function () { closeAppSettings(); if (typeof openDataManagementModal === 'function') openDataManagementModal(); }); 1297 } 1298 } 1299 1300 // Pull the profiles row for the signed-in user, then refresh any visible 1301 // surfaces. Safe to call repeatedly (auth hydration fires a few times). 1302 function syncProfile() { 1303 if (!getUser()) return; 1304 fetchProfile().then(function () { 1305 if (typeof window.updateHeaderAuth === 'function') window.updateHeaderAuth(); 1306 refreshAvatarVisuals(); 1307 // Profile now lives in the Settings modal â re-render its Account 1308 // section if the modal is open on it.
1309 var overlay = document.getElementById('lj-settings-overlay'); 1310 if (overlay && overlay.classList.contains('is-open')) { 1311 var active = overlay.querySelector('.lj-settings-navbtn.is-active'); 1312 if (active && active.getAttribute('data-section') === 'account') showSettingsSection('account'); 1313 } 1314 maybeOnboardUsername(); 1315 }); 1316 } 1317 1318 // ------------------------------------------- username onboarding (new users) 1319 // One-time prompt right after registration so a fresh trader claims their 1320 // permanent @handle immediately, instead of discovering it later buried in 1321 // the profile page. Reuses the same checkUsername + saveProfileRow pipeline 1322 // as the profile editor. "Skip for now" preserves the claim-later model. 1323 var _onbBuilt = false; 1324 var _onbShown = false; 1325 1326 function ensureUsernameOnboardingModal() { 1327 var existing = document.getElementById('lj-username-onboarding'); 1328 if (_onbBuilt && existing) return existing; 1329 var ov = document.createElement('div'); 1330 ov.id = 'lj-username-onboarding'; 1331 ov.className = 'lj-onb-overlay'; 1332 ov.innerHTML = '' + 1333 '<div class="lj-onb-modal" role="dialog" aria-modal="true" aria-labelledby="lj-onb-title">' + 1334 '<div class="lj-onb-emoji" aria-hidden="true">ð</div>' + 1335 '<h2 class="lj-onb-title" id="lj-onb-title">Claim your @username</h2>' + 1336 '<p class="lj-onb-sub">This is your unique @handle on LiJournal. Pick carefully: <strong>your handle is permanent</strong>.</p>' + 1337 '<div class="lj-onb-field">' + 1338 '<div class="profile-username-wrap">' + 1339 '<span class="profile-username-at">@</span>' + 1340 '<input type="text" id="lj-onb-input" class="profile-input profile-username-input" maxlength="20" autocomplete="off" spellcheck="false" placeholder="yourname">' + 1341 '</div>' + 1342 '<div class="profile-field-hint" id="lj-onb-hint">3â20 characters · lowercase letters, numbers, underscore</div>' + 1343 '</div>' + 1344 '<div class="lj-onb-actions">' + 1345 '<button type="button" class="lj-onb-skip" id="lj-onb-skip">Skip for now</button>' + 1346 '<button type="button" class="lj-onb-claim" id="lj-onb-claim" disabled>Claim</button>' + 1347 '</div>' + 1348 '</div>'; 1349 document.body.appendChild(ov); 1350 _onbBuilt = true; 1351 1352 var input = ov.querySelector('#lj-onb-input'); 1353 var hint = ov.querySelector('#lj-onb-hint'); 1354 var claim = ov.querySelector('#lj-onb-claim'); 1355 var skip = ov.querySelector('#lj-onb-skip'); 1356 var state = 'idle'; // idle | checking | ok | bad 1357 1358 function setHint(text, cls) { hint.textContent = text; hint.className = 'profile-field-hint' + (cls ? ' ' + cls : ''); } 1359 1360 input.addEventListener('input', function () { 1361 var v = input.value.toLowerCase().replace(/[^a-z0-9_]/g, ''); 1362 if (v !== input.value) input.value = v; 1363 claim.disabled = true; 1364 if (v === '') { state = 'idle'; setHint('3â20 characters · lowercase letters, numbers, underscore'); return; } 1365 if (!USERNAME_RE.test(v)) { state = 'bad'; setHint('Must be 3â20 chars: aâz, 0â9, _', 'is-bad'); return; } 1366 state = 'checking'; setHint('Checking availabilityâ¦'); 1367 checkUsername(v).then(function (r) { 1368 if (input.value.toLowerCase() !== v) return; // changed since 1369 if (r.available) { state = 'ok'; setHint('@' + v + ' is available', 'is-ok'); claim.disabled = false; } 1370 else if (r.reason === 'taken') { state = 'bad'; setHint('@' + v + ' is taken', 'is-bad'); } 1371 else if (r.reason === 'error') { state = 'idle'; setHint('Couldnât check right now â try again', 'is-bad'); } 1372 else { state = 'bad'; setHint('Invalid username', 'is-bad'); } 1373 }); 1374 }); 1375 input.addEventListener('keydown', function (e) { 1376 if (e.key === 'Enter' && !claim.disabled) { e.preventDefault(); claim.click(); } 1377 }); 1378
1379 claim.onclick = function () { 1380 var v = input.value.toLowerCase().trim(); 1381 if (!USERNAME_RE.test(v) || state !== 'ok') { input.focus(); return; } 1382 claim.disabled = true; claim.textContent = 'Claimingâ¦'; 1383 saveProfileRow({ username: v }) 1384 .then(function () { 1385 closeUsernameOnboarding(); 1386 if (typeof window.updateHeaderAuth === 'function') window.updateHeaderAuth(); 1387 refreshAvatarVisuals(); 1388 var page = document.getElementById('profile-page'); 1389 if (page && page.classList.contains('active')) renderProfile(); 1390 }) 1391 .catch(function (e) { 1392 claim.disabled = false; claim.textContent = 'Claim'; 1393 if (usernameTaken(e)) { state = 'bad'; setHint('@' + v + ' was just taken â try another', 'is-bad'); input.focus(); } 1394 else { setHint('Could not claim right now â try again', 'is-bad'); } 1395 }); 1396 }; 1397 skip.onclick = function () { closeUsernameOnboarding(); }; 1398 return ov; 1399 } 1400 1401 function closeUsernameOnboarding() { 1402 var ov = document.getElementById('lj-username-onboarding'); 1403 if (ov) ov.classList.remove('is-open'); 1404 } 1405 1406 function openUsernameOnboarding() { 1407 var ov = ensureUsernameOnboardingModal(); 1408 if (!ov) return; 1409 ov.classList.add('is-open'); 1410 var input = ov.querySelector('#lj-onb-input'); 1411 if (input) setTimeout(function () { try { input.focus(); } catch (e) {} }, 60); 1412 } 1413 1414 // Show the one-time prompt for a freshly registered user who has no handle 1415 // yet. Defers while the timezone onboarding modal is up so the two required 1416 // dialogs never stack on top of each other. 1417 function maybeOnboardUsername() { 1418 if (_onbShown) return; 1419 if (!getUser() || !_profileTableReady) return; // pre-migration â claim later from profile 1420 if (getUsername()) return; // already has a handle 1421 // The post-purchase welcome wizard (index.html) drives username as step 2 1422 // of Welcome â Username â Timezone â Tutorial. While that's pending, or 1423 // while the user is still behind the checkout gate, don't surface it on 1424 // our own â let the wizard sequence it (or wait until they're entitled). 1425 try { if (localStorage.getItem('lj_welcome_pending') === '1') return; } catch (e) {} 1426 if (typeof window.ljPlanState === 'function') { 1427 var ps = window.ljPlanState(); 1428 if (ps && ps.plan !== 'guest' && !ps.entitled) return; // gated â wait 1429 } 1430 var flag; 1431 try { flag = sessionStorage.getItem('lj_onboard_username') === '1'; } catch (e) { flag = false; } 1432 if (!flag) return; 1433 var tz = document.getElementById('timezone-onboarding-modal'); 1434 if (tz && tz.classList.contains('active')) { setTimeout(maybeOnboardUsername, 800); return; } 1435 _onbShown = true; 1436 try { sessionStorage.removeItem('lj_onboard_username'); } catch (e) {} 1437 openUsernameOnboarding(); 1438 } 1439 1440 // ----------------------------------------------------------------- export 1441 window.navigateToProfile = navigateToProfile; 1442 window.navigateToProfilePage = navigateToProfilePage; 1443 window.renderProfile = renderProfile; 1444 window.openAppSettings = openAppSettings; 1445 window.closeAppSettings = closeAppSettings; 1446 window.ljSyncProfile = syncProfile; 1447 window.ljMaybeOnboardUsername = maybeOnboardUsername; 1448 window.ljPersistProfileSummary = persistProfileSummary; // used by friends.js recompute 1449 // Exposed so the hard gate can show "who am I logged in as" (helps when 1450 // testing across several accounts). Returns '' if no handle yet. 1451 window.ljGetUsername = function () { try { return getUsername() || ''; } catch (e) { return ''; } }; 1452 // Forced opener for the post-purchase welcome wizard (skips the one-shot 1453 // flag/guards). Returns false if the user already has a handle (nothing to 1454 // claim) so the wizard can advance straight to the next step. 1455 window.ljOpenUsernameOnboarding = function () { 1456 if (getUsername()) return false;
1457 _onbShown = true; 1458 try { sessionStorage.removeItem('lj_onboard_username'); } catch (e) {} 1459 openUsernameOnboarding(); 1460 return true; 1461 }; 1462 window.ljUsernameOnboardingOpen = function () { 1463 var ov = document.getElementById('lj-username-onboarding'); 1464 return !!(ov && ov.classList.contains('is-open')); 1465 }; 1466 1467 function init() { 1468 installHeaderHook(); 1469 syncProfile(); 1470 // Re-sync header + profile shortly after auth hydration settles. 1471 setTimeout(function () { installHeaderHook(); syncProfile(); }, 1500); 1472 setTimeout(function () { installHeaderHook(); syncProfile(); }, 4000); 1473 } 1474 1475 if (document.readyState === 'loading') { 1476 document.addEventListener('DOMContentLoaded', init); 1477 } else { 1478 init(); 1479 } 1480})();
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.