PageSourceSearch

https://lijournal.com/account-ui.js?v=v1.7-8-p152

js lijournal.com collected 2026-09-26 04:57:23 UTC 87,847 bytes, 1,480 lines download raw bytes

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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 &amp; P&amp;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 &amp; 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 &amp; 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 &amp; security', 'Login &amp; 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.