PageSourceSearch

https://phantomleads.ai/app/js/pages/auth.js?v=1378

js phantomleads.ai collected 2026-10-05 12:24:27 UTC 86,111 bytes, 1,465 lines download raw bytes

1/* ═══════════════════════════════════════════════════════════════
2   PHANTOM Dashboard - Auth Page (Login / Sign Up / Reset)
3   Uses Supabase Auth — real JWT sessions, email verification,
4   bcrypt-hashed passwords, built-in rate limiting.
5   ═══════════════════════════════════════════════════════════════ */
6
7// ── LOAD USER PROFILE INTO CACHE ────────────────────────────
8// IMPORTANT: This MUST be at the top level (not inside render_auth)
9// so it's available immediately when the script loads. The init flow
10// in app.js calls window.loadUserProfile on page refresh BEFORE
11// render_auth() has a chance to run.
12
13async function loadUserProfile(userId) {
14    let profile, error;
15    try {
16        const res = await supabase
17            .from('profiles')
18            .select('*')
19            .eq('id', userId)
20            .single();
21        profile = res.data;
22        error = res.error;
23    } catch (e) {
24        console.error('[Auth] loadUserProfile network error:', e);
25        return;
26    }
27
28    if (profile && !error) {
29        // Strip sensitive data - only keep boolean flag, never expose ciphertext
30        const gmailConnected = !!(profile.gmail_app_password);
31        delete profile.gmail_app_password;
32
33        // Populate MOCK_USER with real data from database
34        MOCK_USER.id = profile.id;
35        MOCK_USER.email = profile.email;
36        MOCK_USER.name = profile.name;
37        MOCK_USER.tier = profile.tier;
38        MOCK_USER.status = profile.status;
39        MOCK_USER.discoveries_used = profile.discoveries_used;
40        MOCK_USER.discoveries_limit = profile.discoveries_limit;
41        MOCK_USER.chat_messages_used = profile.chat_messages_used || 0;
42        MOCK_USER.deliverables_used = profile.deliverables_used || 0;
43        MOCK_USER.outreach_used = profile.outreach_used || 0;
44        MOCK_USER.outreach_limit = profile.outreach_limit || 0;
45        MOCK_USER.content_posts_used = profile.content_posts_used || 0;
46        MOCK_USER.chat_limit = profile.chat_limit || 0;
47        MOCK_USER.creatives_limit = profile.creatives_limit || 0;
48        MOCK_USER.content_limit = profile.content_limit || 0;
49        MOCK_USER.clients_limit = profile.clients_limit || 0;
50        MOCK_USER.deals_limit = profile.deals_limit || 0;
51        MOCK_USER.active_modules = profile.active_modules || null;
52        MOCK_USER.pending_modules = profile.pending_modules || null;
53        MOCK_USER.billing_cycle_start = profile.billing_cycle_start;
54        MOCK_USER.billing_cycle_end = profile.billing_cycle_end;
55        MOCK_USER.subscription_status = profile.subscription_status || 'none';
56        MOCK_USER.trial_ends_at = profile.trial_ends_at || null;
57        MOCK_USER.trial_exit_reason = profile.trial_exit_reason || null;
58        MOCK_USER.trial_exit_feedback = profile.trial_exit_feedback || null;
59        MOCK_USER.trial_exit_survey_at = profile.trial_exit_survey_at || null;
60        MOCK_USER.payment_failed_at = profile.payment_failed_at || null;
61        MOCK_USER.is_admin = profile.is_admin || false;
62        // Per-user feature flag overrides (admin-gated UI rollouts).
63        // JSONB column added in migration 068. Default {} keeps users on
64        // standard UI until a flag is flipped server-side.
65        MOCK_USER.feature_flags = profile.feature_flags || {};
66        // v2 dashboard rollout: SHELVED. Flag/cookie cleared in app/index.html
67        // boot script. Auth no longer routes any user to /app/v2/.
68        // Admins always get operator-level access to all features
69        if (MOCK_USER.is_admin) {
70            MOCK_USER.tier = 'operator';
71            MOCK_USER.discoveries_limit = Math.max(MOCK_USER.discoveries_limit || 0, 5000);
72            MOCK_USER.subscription_status = 'active';
73        }
74        MOCK_USER.theme = profile.theme;
75        MOCK_USER.xp = profile.xp ?? 0;
76        MOCK_USER.level = profile.level ?? 1;
77        MOCK_USER.total_xp = profile.total_xp ?? 0;
78        MOCK_USER.prestige = profile.prestige ?? 0;
79
80        // Self-healing XP reconciliation: normalize xp < 500 and fix total_xp consistency
81        // This prevents endless level-up loops from corrupted DB state
82        (function() {
83            const threshold = typeof XP_CONFIG !== 'undefined' ? XP_CONFIG.xpForLevel((MOCK_USER.level ?? 1) + 1) : 500;
84            const maxLvl = typeof XP_CONFIG !== 'undefined' ? XP_CONFIG.maxLevel : 100;
85            let adjusted = false;
86            // Consume excess xp into level-ups (NO celebrations - this is correction, not new progress)
87            while (MOCK_USER.xp >= threshold && MOCK_USER.level < maxLvl) {
88                MOCK_USER.xp -= threshold;
89                MOCK_USER.level = (MOCK_USER.level ?? 1) + 1;
90                adjusted = true;
91            }
92            // Fix total_xp if it's too low for the current level + xp
93            const minTotal = (MOCK_USER.level - 1) * threshold + MOCK_USER.xp;
94            if (MOCK_USER.total_xp < minTotal) {
95                MOCK_USER.total_xp = minTotal;
96                adjusted = true;
97            }
98            if (adjusted) {
99                console.warn('[Auth] XP reconciled on load:', { xp: MOCK_USER.xp, level: MOCK_USER.level, total_xp: MOCK_USER.total_xp });
100                supabase.from('profiles').update({
101                    xp: MOCK_USER.xp, level: MOCK_USER.level, total_xp: MOCK_USER.total_xp,
102                }).eq('id', profile.id).then(() => {
103                    console.log('[Auth] XP reconciliation persisted');
104                }).catch(e => console.error('[Auth] XP reconciliation persist failed:', e));
105            }
106        })();
107
108        MOCK_USER.is_founder = profile.is_founder || false;
109        MOCK_USER.equipped_badges = profile.equipped_badges || [];
110        MOCK_USER.streak_days = profile.streak_days ?? 0;
111        MOCK_USER.last_login_date = profile.last_login_date || null;
112        MOCK_USER.member_since = profile.member_since;
113        MOCK_USER.stripe_customer_id = profile.stripe_customer_id || null;
114        MOCK_USER.stripe_subscription_id = profile.stripe_subscription_id || null;
115        MOCK_USER.stripe_price_id = profile.stripe_price_id || null;
116        MOCK_USER.cancel_at_period_end = profile.cancel_at_period_end || false;
117        MOCK_USER.stripe_discount_percent = profile.stripe_discount_percent || 0;
118        MOCK_USER.stripe_discount_duration = profile.stripe_discount_duration || null;
119        MOCK_USER.guarantee_claimed = profile.guarantee_claimed || false;
120        MOCK_USER.guarantee_requested = profile.guarantee_requested || false;
121        MOCK_USER.guarantee_denied_reason = profile.guarantee_denied_reason || null;
122        MOCK_USER.stripe_connect_status = profile.stripe_connect_status || 'not_connected';
123        MOCK_USER.stripe_charges_enabled = profile.stripe_charges_enabled || false;
124        MOCK_USER.stripe_payouts_enabled = profile.stripe_payouts_enabled || false;
125        MOCK_USER.invoice_revenue_collected = parseFloat(profile.invoice_revenue_collected) || 0;
126        MOCK_USER.creative_bonus = profile.creative_bonus || 0;
127        if (profile.notifications) {
128            MOCK_USER.notifications = profile.notifications;
129        }
130
131        // Populate business profile from profile columns
132        MOCK_BUSINESS_PROFILE.business_name = profile.business_name || '';
133        MOCK_BUSINESS_PROFILE.industry = profile.industry || '';
134        MOCK_BUSINESS_PROFILE.website = profile.business_website || '';
135        MOCK_BUSINESS_PROFILE.email = profile.business_email || '';
136        MOCK_BUSINESS_PROFILE.phone = profile.business_phone || '';
137        MOCK_BUSINESS_PROFILE.city = profile.city || '';
138        MOCK_BUSINESS_PROFILE.tagline = profile.tagline || '';
139        MOCK_BUSINESS_PROFILE.description = profile.business_description || '';
140
141        // Populate account setup from profile columns
142        MOCK_ACCOUNT_SETUP.gmail_address = profile.gmail_address || '';
143        MOCK_ACCOUNT_SETUP.google_api_key = profile.google_api_key || '';
144        MOCK_ACCOUNT_SETUP.email_provider = profile.email_provider || '';
145        MOCK_ACCOUNT_SETUP.smtp_host = profile.smtp_host || '';
146        MOCK_ACCOUNT_SETUP.smtp_port = profile.smtp_port || 0;
147        MOCK_ACCOUNT_SETUP.imap_host = profile.imap_host || '';
148        MOCK_ACCOUNT_SETUP.imap_port = profile.imap_port || 0;
149        MOCK_ACCOUNT_SETUP.timezone = profile.timezone_pref || 'ET';
150        MOCK_ACCOUNT_SETUP.notification_preferences = profile.notification_pref || 'both';
151
152        // Populate outreach preferences
153        MOCK_USER.outreach_tone = profile.outreach_tone || 'professional';
154        MOCK_USER.outreach_signature = profile.outreach_signature || '';
155        MOCK_USER.outreach_cta = profile.outreach_cta || '';
156
157        // Populate onboarding & new fields
158        MOCK_USER.onboarding_completed = profile.onboarding_completed || false;
159        MOCK_USER.onboarding_step = profile.onboarding_step || 0;
160        // Demo-first onboarding state: 'demo' | 'trialing' | 'active' | 'cancelled' | null
161        // Backend agent adds this column to profiles. When 'demo', dashboard renders s
161ample
162        // data and the activation banner is shown until user picks a plan.
163        MOCK_USER.onboarding_state = profile.onboarding_state || null;
164
165        // If onboarding not completed, flag as first-time user and auto-complete
166        // so they skip the old onboarding wizard and go straight to Lead Finder
167        if (!MOCK_USER.onboarding_completed) {
168            window._isFirstTimeUser = true;
169            MOCK_USER.onboarding_completed = true;
170            MOCK_USER.onboarding_step = 6;
171            API.updateOnboardingProfile({ onboarding_completed: true, onboarding_step: 6 }).catch(() => {});
172        }
173        MOCK_USER.email_automation_level = profile.email_automation_level || 'manual';
174        MOCK_USER.email_automation_tos_accepted = profile.email_automation_tos_accepted || false;
175        MOCK_USER.marketplace_agreement_signed = profile.marketplace_agreement_signed || false;
176        MOCK_USER.marketplace_agreement_version = profile.marketplace_agreement_version || null;
177        MOCK_USER.marketplace_available = profile.marketplace_available || false;
178        MOCK_USER.marketplace_skills = profile.marketplace_skills || [];
179        MOCK_USER.marketplace_needs = profile.marketplace_needs || [];
180        MOCK_USER.target_niches = profile.target_niches || [];
181        MOCK_USER.target_cities = profile.target_cities || [];
182        MOCK_USER.target_country = profile.target_country || 'US';
183        MOCK_USER.activation_milestones = profile.activation_milestones || {};
184        MOCK_USER.booking_link = profile.booking_link || '';
185        MOCK_USER.base_rate = profile.base_rate || '';
186        MOCK_USER.avatar_url = profile.avatar_url || null;
187        // Only store whether gmail is connected - password ciphertext already stripped above
188        MOCK_ACCOUNT_SETUP.gmail_connected = gmailConnected;
189
190        // Populate gamification fields
191        MOCK_USER.last_friday_bonus_date = profile.last_friday_bonus_date || null;
192
193        // Populate role & equipment
194        MOCK_USER.primary_role = profile.primary_role || '';
195        MOCK_USER.equipment = profile.equipment || [];
196        MOCK_USER.shoots_video = profile.shoots_video || false;
197
198        // Populate portfolio builder fields
199        MOCK_USER.portfolio_enabled = profile.portfolio_enabled || false;
200        MOCK_USER.portfolio_headline = profile.portfolio_headline || '';
201        MOCK_USER.portfolio_theme = profile.portfolio_theme || 'dark';
202        MOCK_USER.portfolio_accent = profile.portfolio_accent || '#7eb8ff';
203        MOCK_USER.portfolio_services = profile.portfolio_services || [];
204        MOCK_USER.portfolio_images = profile.portfolio_images || [];
205        MOCK_USER.portfolio_cta_text = profile.portfolio_cta_text || 'Work With Me';
206        MOCK_USER.portfolio_cta_url = profile.portfolio_cta_url || '';
207        MOCK_USER.portfolio_generated_at = profile.portfolio_generated_at || null;
208        MOCK_USER.username = profile.username || '';
209        MOCK_USER.has_custom_username = !!profile.username;
210
211        // Populate signup attribution fields
212        MOCK_USER.signup_source = profile.signup_source || null;
213        MOCK_USER.acquisition_source = profile.acquisition_source || 'direct';
214        MOCK_USER.utm_source = profile.utm_source || null;
215        MOCK_USER.utm_medium = profile.utm_medium || null;
216        MOCK_USER.utm_campaign = profile.utm_campaign || null;
217
218        // Populate payment method card details
219        MOCK_USER.card_last4 = profile.card_last4 || '';
220        MOCK_USER.card_brand = profile.card_brand || '';
221        MOCK_USER.card_exp = profile.card_exp || '';
222
223        // Check force logout flag (set by admin)
224        if (profile.force_logout_at) {
225            const sessionData = await supabase.auth.getSession();
226            const sessionStart = sessionData?.data?.session?.created_at;
227            if (!sessionStart || new Date(profile.force_logout_at) > new Date(sessionStart)) {
228                console.warn('[Auth] Force logout triggered by admin');
229                const { error: clearErr } = await supabase.from('profiles').update({ force_logout_at: null }).eq('id', profile.id);
230                if (clearErr) console.error('[Auth] Failed to clear force_logout_at:', clearErr.message);
231                await supabase.auth.signOut();
232                window.location.hash = '#/login';
233                return;
234            }
235        }
236
237        // Check banned status
238        if (profile.banned) {
239            console.warn('[Auth] Account is banned:', profile.ban_reason || 'No reason given');
240            await supabase.auth.signOut();
241            window.location.hash = '#/login';
242            if (typeof Toast !== 'undefined') Toast.error('Your account has been suspended. Contact support for more information.');
243            return;
244        }
245
246        // Mark profile as successfully loaded - used by Router onboarding guard
247        window._profileLoaded = true;
248        // Profile loaded successfully
249    } else {
250        console.warn('[Auth] Profile load failed:', error?.message || 'no profile found');
251        window._profileLoadFailed = true;
252        // Security: force sign-out so a banned or deleted user cannot enter the app
253        // even during a transient load failure window.
254        if (window._profileLoadFailed) {
255            console.warn('[Auth] Profile load failed - signing out for security');
256            if (typeof Toast !== 'undefined') Toast.error('Could not verify your account. Please log in again.');
257            try { await supabase.auth.signOut(); } catch(e) {}
258            window.location.hash = '#/login';
259            return;
260        }
261    }
262}
263
264// Expose globally for session restore in app.js init flow
265window.loadUserProfile = loadUserProfile;
266
267
268function render_auth(container) {
269    const _esc = PhantomUtils.esc;
270    let mode = 'login'; // 'login', 'signup', 'reset', 'verify'
271    let errorMsg = '';
272    let successMsg = '';
273    let loading = false;
274    let _verifyEmail = sessionStorage.getItem('phantom-verify-email') || ''; // Persist across refreshes
275
276    // Capture UTM params from URL and merge into phantom-utm localStorage (for direct auth page landings)
277    {
278        const _utmSearch = new URLSearchParams(window.location.search);
279        const _utmHash = new URLSearchParams(window.location.hash.split('?')[1] || '');
280        const _utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'];
281        const _newUtm = {};
282        let _hasUtm = false;
283        _utmKeys.forEach(key => {
284            const val = _utmHash.get(key) || _utmSearch.get(key);
285            if (val) { _newUtm[key] = val; _hasUtm = true; }
286        });
287        if (_hasUtm) {
288            try {
289                const _existing = JSON.parse(localStorage.getItem('phantom-utm') || '{}');
290                localStorage.setItem('phantom-utm', JSON.stringify(Object.assign(_existing, _newUtm)));
291            } catch(e) {
292                localStorage.setItem('phantom-utm', JSON.str
292ingify(_newUtm));
293            }
294        }
295    }
296
297    // Detect promo code from URL param (e.g. ?promo=CLOSEFRIENDS30)
298    // Check both search params (?promo=X) and hash params (#/login?promo=X)
299    const _authUrlParams = new URLSearchParams(window.location.search);
300    const _authHashParams = new URLSearchParams(window.location.hash.split('?')[1] || '');
301    const _urlPromo = _authHashParams.get('promo') || _authUrlParams.get('promo') || null;
302    if (_urlPromo) localStorage.setItem('phantom-promo', _urlPromo.toUpperCase());
303
304    // Detect team invite token from URL param (?team_invite=TOKEN or #/login?team_invite=TOKEN)
305    const _teamInviteToken = _authHashParams.get('team_invite') || _authUrlParams.get('team_invite') || null;
306    if (_teamInviteToken) {
307        sessionStorage.setItem('phantom-team-invite', _teamInviteToken);
308        // Clean URL without removing hash-based routing
309        const cleanUrl = window.location.pathname + window.location.hash.split('?')[0];
310        history.replaceState(null, '', cleanUrl);
311    }
312
313    // Detect invite token from URL param
314    const _inviteToken = _authUrlParams.get('invite') || localStorage.getItem('phantom-invite-token') || null;
315    if (_inviteToken) {
316        localStorage.setItem('phantom-invite-token', _inviteToken);
317        mode = 'signup'; // Auto-switch to signup for invited users
318    }
319
320    // Detect tier/plan from URL param or localStorage (from homepage pricing CTA)
321    // Also store plan from hash params into localStorage for cross-browser safety
322    const _hashPlan = _authHashParams.get('plan') || null;
323    if (_hashPlan) localStorage.setItem('phantom-selected-plan', JSON.stringify({ plan: _hashPlan, ts: Date.now() }));
324    const _authAction = _authHashParams.get('action') || null;
325    if (_authAction === 'signup') mode = 'signup';
326    let _requestedTier = _authHashParams.get('plan') || _authUrlParams.get('tier') || null;
327    if (!_requestedTier && !_inviteToken) {
328        try {
329            const stored = localStorage.getItem('phantom-selected-plan');
330            if (stored) {
331                const parsed = JSON.parse(stored);
332                if (typeof parsed === 'string') _requestedTier = parsed;
333                else if (parsed?.plan && parsed.ts && Date.now() - parsed.ts < 259200000) _requestedTier = parsed.plan;
334            }
335        } catch (e) { _requestedTier = localStorage.getItem('phantom-selected-plan'); }
336    }
337    if (_requestedTier) {
338        mode = 'signup'; // Auto-switch to signup tab when tier/plan param present
339    }
340
341    // Native iOS: allow both login and signup. Payment happens on web via Stripe Checkout.
342    // iOS users who sign up are redirected to phantomleads.ai for checkout completion.
343
344    // Helper: show an error inline without re-rendering the entire form
345    function showError(msg) {
346        errorMsg = msg;
347        successMsg = '';
348        const errEl = container.querySelector('.auth-error');
349        const succEl = container.querySelector('.auth-success');
350        if (succEl) succEl.remove();
351        if (errEl) {
352            errEl.textContent = msg;
353            errEl.style.display = msg ? '' : 'none';
354        } else if (msg) {
355            const card = container.querySelector('.auth-card');
356            const tabs = card?.querySelector('.auth-tabs');
357            const div = document.createElement('div');
358            div.className = 'auth-error';
359            div.textContent = msg;
360            if (tabs) tabs.after(div);
361            else card?.querySelector('.auth-logo')?.after(div);
362        }
363    }
364
365    function render() {
366        container.innerHTML = `
367            <div class="auth-container">
368                <div class="auth-card glass-card-strong glow-border saber-pulse">
369                    <div class="auth-logo">
370                        <img src="../assets/Phantom ghost logo only transfparent.png" alt="Phantom" onerror="this.style.display='none'">
371                        <span>PHANTOM</span>
372                    </div>
373
374                    ${mode === 'verify' ? '' : `
375                    <div class="auth-tabs">
376                        <button class="auth-tab ${mode === 'login' ? 'active' : ''}" data-mode="login">Log In</button>
377                        <button class="auth-tab ${mode === 'signup' ? 'active' : ''}" data-mode="signup">Sign Up</button>
378                    </div>`}
379
380                    ${sessionStorage.getItem('phantom-team-invite') ? `
381                    <div style="background:rgba(var(--accent-rgb),0.08);border:1px solid rgba(var(--accent-rgb),0.2);border-radius:10px;padding:12px 14px;margin-bottom:16px;text-align:center">
382                        <p style="color:var(--text);font-size:0.85rem;margin:0 0 4px;font-weight:600">You have been invited to a Phantom workspace</p>
383                        <p style="color:var(--text-muted);font-size:0.78rem;margin:0">Log in or sign up below to accept the invite.</p>
384                    </div>` : ''}
385
386                    ${errorMsg ? `<div class="auth-error">${sanitize(errorMsg)}</div>` : ''}
387                    ${successMsg ? `<div class="auth-success" style="color:#4CAF50;background:rgba(76,175,80,0.1);padding:10px 14px;border-radius:8px;font-size:0.85rem;margin-bottom:12px">${sanitize(successMsg)}</div>` : ''}
388
389                    ${mode === 'login' ? renderLogin() :
390                      mode === 'signup' ? renderSignup() :
391                      mode === 'reset' ? renderReset() :
392                      renderVerify()}
393                </div>
394
395                <div class="auth-footer" style="margin-top:20px">
396                    ${window.PHANTOM_NATIVE ? '' : '<a href="/" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-muted);font-size:0.85rem;text-decoration:none;margin-bottom:10px;transition:color 0.2s" onmouseover="this.style.color=\'var(--accent)\'" onmouseout="this.style.color=\'var(--text-muted)\'">&larr; Back to Homepage</a><br>'}
397                    <span class="text-xs text-muted">Phantom AI-Powered Lead Intelligence</span>
398                </div>
399            </div>`;
400
401        // Tab switching
402        container.querySelectorAll('.auth-tab').forEach(tab => {
403            tab.addEventListener('click', () => {
404                mode = tab.dataset.mode;
405                errorMsg = '';
406                successMsg = '';
407                render();
408            });
409        });
410
411        // Login form
412        const loginForm = container.querySelector('#login-form');
413        if (loginForm) {
414            loginForm.addEventListener('submit', handleLogin);
415        }
416
417        // Google OAuth
418        container.querySelector('#google-auth-btn')?.addEventListener('click', async () => {
419            const btn = container.querySelector('#google-auth-btn');
420            if (btn) {
421                btn.disabled = true;
422                btn.style.opacity = '0.7';
423                const span = btn.querySelector('span');
424                if (span) span.textContent = 'Redirecting...';
425            }
426            // Store platform + acquisition source for post-OAuth attribution
427            const _platform = window.__TAURI_INTERNALS__ ? 'desktop' : (window.PHANTOM_NATIVE ? 'ios' : 'web');
428            localStorage.setItem('phantom-signup-platform', _platform);
429            // Capture user-picked acquisition source before OAuth redirect
430            const _pickedSource = container.querySelector('#acquisition-source')?.value || '';
431            if (_pickedSource) localStorage.setItem('phantom-pending-acq-source', _pickedSource);
432            const _dlRef = localStorage.getItem('phantom-download-ref');
433            if (_dlRef) localStorage.setItem('phantom-pending-download-ref', _dlRef);
434
435            if (window.__TAURI_INTERNALS__) {
436                // Desktop app: open OAuth in system browser, return via deep link
437                const { data, error } = await supabase.auth.signInWithOAuth({
438                    provider: 'google',
439                    options: {
440                        redirectTo: 'https://phantomleads.ai/app/?from=desktop',
441                        queryParams: { prompt: 'select_account' },
442                        skipBrowserRedirect: true
443                    }
444                });
445                if (error || !data?.url) {
446                    if (btn) { btn.disabled = false; btn.style.opacity = ''; const span = btn.querySelector('span'); if (span) span.textContent = 'Continue with Google'; }
447                    Toast.error('Google sign-in failed. Please try email instead.');
448                } else {
449                    // Open in system browser (not webview) - deep link returns tokens
450                    window.__phantom_desktop_oauth_pending = true;
451                    try {
452                        await window.__TAURI_INTERNALS__.invoke('plugin:shell|open', { path: data.url });
453                        // Update button to show waiting state
454                        if (btn) { const span = btn.querySelector('span'); if (span) span.textContent = 'Complete sign-in in browser...'; }
455                    } catch (e) {
456                        window.__phantom_desktop_oauth_pending = false;
457                        // Fallback: navigate webview directly
458                        window.location.href = data.url;
459                    }
460                }
461            } else if (window.PHANTOM_NATIVE) {
462                // iOS: open Google OAuth in SFSafariViewController (in-app browser sheet)
463                // After OAuth, Supabase redirects to /api/auth-native-callback which
464                // extracts tokens from the hash and redirects to phantomleads:// custom
465                // URL scheme. Capacitor intercepts this and fires appUrlOpen with tokens.
466                window._phantomOAuthPending = true;
467                const { data, error } = await supabase.auth.signInWithOAuth({
468                    provider: 'google',
469                    options: {
470                        redirectTo: window.PHANTOM_API_BASE + '/api/auth-native-callback',
471                        queryParams: { prompt: 'select_account' },
472                        skipBrowserRedirect: true
473                    }
474                });
475                if (error || !data?.url) {
476                    window._phantomOAuthPending = false;
477                    if (btn) { btn.disabled = false; btn.style.opacity = ''; const span = btn.querySelector('span'); if (span) span.textContent = 'Continue with Google'; }
478                    Toast.error('Google sign-in failed. Please try email instead.');
479                } else {
480                    const Browser = window.Capacitor?.Plugins?.Browser;
481                    if (Browser) {
482                        Browser.open({ url: data.url, presentationStyle: 'popover' });
483                        if (btn) { const span = btn.querySelector('span'); if (span) span.textContent = 'Complete sign-in...'; }
484                    } else {
485                        window.location.href = data.url;
486                    }
487                }
488            } else {
489                // Web: standard OAuth redirect
490                const { error } = await supabase.auth.signInWithOAuth({
491                    provider: 'google',
492                    options: {
493                        redirectTo: window.location.origin + '/app/',
494                        queryParams: { prompt: 'select_account' }
495                    }
496                });
497                if (error) {
498                    if (btn) { btn.disabled = false; btn.style.opacity = ''; const span = btn.querySelector('span'); if (span) span.textContent = 'Continue with Google'; }
499                    Toast.error('Google sign-in failed. Please try email instead.');
500                }
501            }
502        });
503
504        // Apple Sign In (native iOS only)
505        container.querySelector('#apple-auth-btn')?.addEventListener('click', async () => {
506            const btn = container.querySelector('#apple-auth-btn');
507            const resetBtn = () => { if (btn) { btn.disabled = false; btn.style.opacity = ''; const span = btn.querySelector('span'); if (span) span.textContent = 'Sign in with Apple'; } };
508            if (btn) { btn.disabled = true; btn.style.opacity = '0.7'; const span = btn.querySelector('span'); if (span) span.textContent = 'Signing in...'; }
509            try {
510                // Use native Sign in with Apple via Capacitor plugin
511                const SignInWithApple = window.Capacitor?.Plugins?.SignInWithApple;
512                if (!SignInWithApple) {
513                    console.error('[Auth] SignInWithApple plugin not registered. Capacitor plugins:', Object.keys(window.Capacitor?.Plugins || {}));
514                    Toast.error('Apple Sign In not available. Please use email instead.');
515                    resetBtn();
516                    return;
517                }
518                console.log('[Auth] Starting Apple Sign In authorization...');
519                // Generate random nonce for security
520                const rawNonce = [...crypto.getRandomValues(new Uint8Array(32))].map(b => b.toString(16).padStart(2, '0')).join('');
521                const result = await SignInWithApple.authorize({
522                    clientId: 'ai.phantomleads.app',
523                    redirectURI: 'https://phantomleads.ai/app/',
524                    scopes: 'email name',
525                    state: 'phantom-auth',
526                    nonce: rawNonce
527                });
528                console.log('[Auth] Apple Sign In result received, has token:', !!result?.response?.identityToken);
529                if (result?.response?.identityToken) {
530                    // Exchange Apple token with our server (bypasses Supabase Apple provider)
531                    const apiBase = window.PHANTOM_API_BASE || '';
532                    async function _appleAuthRequest(extraBody) {
533                        const resp = await fetch(apiBase + '/api/apple-auth', {
534                            method: 'POST',
535                            headers: { 'Content-Type': 'application/json' },
536                            body: JSON.stringify({
537                                identityToken: result.response.identityToken,
538                                fullName: result.response.fullName || null,
539                                ...extraBody
540                            })
541                        });
542                        return { resp, body: await resp.json() };
543                    }
544                    console.log('[Auth] Calling /api/apple-auth...');
545                    let { resp, body } = await _appleAuthRequest();
546                    console.log('[Auth] Apple auth response:', resp.status, JSON.stringify(body));
547
548                    if (!resp.ok || !body.access_token) {
549                        console.error('[Auth] Apple auth API error:', resp.status, JSON.stringify(body));
550                        Toast.error(body.error || 'Apple sign-in failed. Please try email instead.');
551                        resetBtn();
552                        return;
553                    }
554                    // Store session directly in localStorage and reload.
555                    // We bypass supabase.auth.setSession() because it hangs when
556                    // onAuthStateChange fires during app resume from Apple Sign In.
557                    // On reload, the Supabase client reads this from storage automatically.
558                    console.log('[Auth] Storing Apple session directly...');
559                    const storageKey = 'sb-eqtzimzqbdjpbaoglkus-auth-token';
560                    const sessionObj = {
561                        access_token: body.access_token,
562                        refresh_token: body.refresh_token,
563                        expires_in: body.expires_in || 3600,
564                        expires_at: Math.floor(Date.now() / 1000) + (body.expires_in || 3600),
565                        token_type: 'bearer',
566                        user: body.user
567                    };
568                    localStorage.setItem(storageKey, JSON.stringify(sessionObj));
569                    localStorage.setItem('phantom-auth', 'true');
570                    console.log('[Auth] Apple sign-in complete - reloading');
571                    const _isFirstTimeApple = window._isFirstTimeUser || localStorage.getItem('phantom-first-time-user') === '1';
572                    window.location.hash = _isFirstTimeApple ? '#/leads' : '#/dashboard';
573                    window.location.reload();
574                } else {
575                    console.warn('[Auth] Apple Sign In returned no identity token. Result:', JSON.stringify(result));
576                    Toast.error('Apple sign-in was cancelled.');
577                    resetBtn();
578                }
579            } catch (err) {
580                console.error('[Auth] Apple sign-in error:', err?.message || err, 'code:', err?.code, 'stack:', err?.stack);
581                // User cancelled = don't show error toast
582                const msg = (err?.message || '').toLowerCase();
583                if (msg.includes('cancel') || err?.code === '1001' || err?.code === 1001) {
584                    // User cancelled - just reset button
585                } else {
586                    Toast.error('Apple sign-in failed. Please try email instead.');
587                }
588                resetBtn();
589            }
590        });
591
592        // Continue with Email / Back to picker
593        // Note: signup view collapsed the picker (email form visible by default), so these
594        // handlers only fire on the login view where #email-auth-btn / #back-to-picker still exist.
595        container.querySelector('#email-auth-btn')?.addEventListener('click', () => {
596            // Carry over source selection from auth-picker to email form
597            var _pickerSource = container.querySelector('#auth-picker #acquisition-source');
598            var _emailFormSource = container.querySelector('#auth-email-form #acquisition-source');
599            if (_pickerSource && _emailFormSource && _pickerSource.value) {
600                _emailFormSource.value = _pickerSource.value;
601                // Also visually select the matching button in the email form
602                var _emailBtns = container.querySelectorAll('#auth-email-form .source-btn');
603                _emailBtns.forEach(function(b) {
604                    b.classList.toggle('selected', b.dataset.source === _pickerSource.value);
605                });
606            }
607            container.querySelector('#auth-picker').style.display = 'none';
608            container.querySelector('#auth-email-form').style.display = '';
609        });
610        container.querySelector('#back-to-picker')?.addEventListener('click', () => {
611            container.querySelector('#auth-email-form').style.display = 'none';
612            container.querySelector('#auth-picker').style.display = '';
613        });
614
615
616        // Demo login - full page reload ensures all MOCK arrays are freshly initialized
617        // (within-SPA demo entry would show empty data if _clearForRealUser ran earlier)
618        container.querySelector('#demo-login-btn')?.addEventListener('click', () => {
619            window.location.href = window.location.pathname + '?demo=1';
620        });
621
622        // Signup form
623        const signupForm = container.querySelector('#signup-form');
624        if (signupForm) {
625            signupForm.addEventListener('submit', handleSignup);
626        }
627
628        // Plan picker buttons
629        container.querySelectorAll('.plan-pick-btn').forEach(btn => {
630            btn.addEventListener('click', () => {
631                _requestedTier = btn.dataset.pickTier;
632                container.querySelectorAll('.plan-pick-btn').forEach(b => {
633                    const isSelected = b.dataset.pickTier === _requestedTier;
634                    b.style.borderColor = isSelected ? 'var(--accent)' : 'var(--border)';
635                    b.style.background = isSelected ? 'rgba(126,184,255,0.12)' : 'rgba(126,184,255,0.04)';
636                    // Update radio circle
637                    const radio = b.querySelector('div > div:first-child');
638                    if (radio) {
639                        radio.style.borderColor = isSelected ? 'var(--accent)' : 'var(--text-muted)';
640                        radio.innerHTML = isSelected ? '<div style="width:8px;height:8px;border-radius:50%;background:var(--accent)"></div>' : '';
641                    }
642                });
643            });
644        });
645
646        // "How did you find us?" source picker
647        container.querySelectorAll('.source-btn').forEach(btn => {
648            btn.addEventListener('click', () => {
649                container.querySelectorAll('.source-btn').forEach(b => b.classList.remove('selected'));
650                btn.classList.add('selected');
651                const hidden = container.querySelector('#acquisition-source');
652                if (hidden) hidden.value = btn.dataset.source;
653            });
654        });
655
656        // Auto-select source from UTM params if available
657        try {
658            var _storedUtm = JSON.parse(localStorage.getItem('phantom-utm') || '{}');
659            var _utmSrc = (_storedUtm.utm_source || '').toLowerCase();
660            var _autoSource = '';
661            if (_utmSrc === 'facebook' || _utmSrc === 'fb' || _utmSrc === 'meta' || _utmSrc === 'ig') _autoSource = _utmSrc === 'ig' ? 'instagram' : 'facebook_ad';
662            else if (_utmSrc === 'instagram') _autoSource = 'instagram';
663            else if (_utmSrc === 'google') _autoSource = 'google';
664            else if (_utmSrc === 'tiktok') _autoSource = 'tiktok';
665            else if (_utmSrc === 'youtube') _autoSource = 'youtube';
666            if (_autoSource) {
667                var _autoBtn = container.querySelector('.source-btn[data-source="' + _autoSource + '"]');
668                if (_autoBtn) {
669                    _autoBtn.classList.add('selected');
670                    var _autoHidden = container.querySelector('#acquisition-source');
671                    if (_autoHidden) _autoHidden.value = _autoSource;
672                }
673            }
674        } catch(e) { /* silent */ }
675
676        // Reset form
677        const resetForm = container.querySelector('#reset-form');
678        if (resetForm) {
679            resetForm.addEventListener('submit', handleReset);
680        }
681
682        // Forgot password link
683        const forgotLink = container.querySelector('[data-forgot]');
684        if (forgotLink) {
685            forgotLink.addEventListener('click', (e) => {
686                e.preventDefault();
687                mode = 'reset';
688                errorMsg = '';
689                successMsg = '';
690                render();
691            });
692        }
693
694        // Back to login link
695        const backLink = container.querySelector('[data-back-login]');
696        if (backLink) {
697            backLink.addEventListener('click', (e) => {
698                e.preventDefault();
699                mode = 'login';
700                errorMsg = '';
701                successMsg = '';
702                render();
703            });
704        }
705
706        // Cross-links ("Don't have an account? Sign up" / "Already have an account? Log in")
707        container.querySelectorAll('.auth-tab-link').forEach(link => {
708            link.addEventListener('click', (e) => {
709                e.preventDefault();
710                // Native signup gate removed - signup works natively now
711                mode = link.dataset.mode;
712                errorMsg = '';
713                successMsg = '';
714                render();
715            });
716        });
717
718        // Resend verification email via Resend (branded)
719        container.querySelector('#resend-verify-btn')?.addEventListener('click', async () => {
720            const btn = container.querySelector('#resend-verify-btn');
721            if (btn) { btn.disabled = true; btn.textContent = 'Sending...'; }
722            try {
723                const resp = await fetch((window.PHANTOM_API_BASE || '') + '/api/verify-email', {
724                    method: 'POST',
725                    headers: { 'Content-Type': 'application/json' },
726                    body: JSON.stringify({ email: _verifyEmail }),
727                });
728                if (btn) { btn.disabled = false; btn.textContent = 'Resend Email'; }
729                if (resp.status === 429) {
730                    Toast.error('Too many attempts. Please wait a few minutes.');
731                } else {
732                    Toast.success('Verification email resent!');
733                }
734            } catch (e) {
735                if (btn) { btn.disabled = false; btn.textContent = 'Resend Email'; }
736                Toast.error('Could not resend. Please wait a moment and try again.');
737            }
738        });
739    }
740
741    // ── Sanitize user-visible strings ────────────────────────────
742    function sanitize(str) {
743        const div = document.createElement('div');
744        div.textContent = str;
745        return div.innerHTML;
746    }
747
748    // ── Set loading state on buttons ────────────────────────────
749    function setLoading(form, isLoading) {
750        loading = isLoading;
751        const btn = form.querySelector('button[type="submit"]');
752        if (btn) {
753            btn.disabled = isLoading;
754            if (isLoading) {
755                btn.dataset.origText = btn.textContent;
756                btn.textContent = 'Please wait...';
757            } else {
758                btn.textContent = btn.dataset.origText || btn.textContent;
759            }
760        }
761    }
762
763    // ── LOGIN ───────────────────────────────────────────────────
764    async function handleLogin(e) {
765        e.preventDefault();
766        if (loading) return;
767
768        const form = e.target;
769        const email = form.querySelector('input[type="email"]').value.trim().toLowerCase();
770        const password = form.querySelector('input[type="password"]').value;
771
772        if (!email || !password) {
773            showError('Please enter your email and password.');
774            return;
775        }
776
777        setLoading(form, true);
778        showError('');
779
780        const { data, error } = await supabase.auth.signInWithPassword({ email, password });
781
782        if (error) {
783            setLoading(form, false);
784            if (error.message.includes('Invalid login')) {
785                showError('');
786                const card = container.querySelector('.auth-card');
787                card?.querySelector('.auth-invalid-banner')?.remove();
788                const invBanner = document.createElement('div');
789                invBanner.className = 'auth-invalid-banner';
790                invBanner.style.cssText = 'background:rgba(255,68,68,0.08);border:1px solid rgba(255,68,68,0.2);border-radius:10px;padding:14px;margin-bottom:14px;text-align:center';
791                invBanner.innerHTML = '<p style="color:var(--text);font-size:0.85rem;margin:0 0 10px">Invalid email or password.</p>'
792                    + '<button class="btn btn-outline btn-sm" id="invalid-reset-pw" style="font-size:0.8rem">Reset Password</button>';
793                const tabs = card?.querySelector('.auth-tabs');
794                if (tabs) tabs.after(invBanner);
795                else card?.querySelector('.auth-logo')?.after(invBanner);
796                document.getElementById('invalid-reset-pw')?.addEventListener('click', () => { mode = 'reset'; errorMsg = ''; successMsg = ''; render(); });
797            } else if (error.message.includes('Email not confirmed')) {
798                // Show error with inline resend option
799                _verifyEmail = email;
800                sessionStorage.setItem('phantom-verify-email', email);
801                showError('');
802                const card = container.querySelector('.auth-card');
803                // Remove any previous verify-banner
804                card?.querySelector('.auth-verify-banner')?.remove();
805                const banner = document.createElement('div');
806                banner.className = 'auth-verify-banner';
807                banner.style.cssText = 'background:rgba(126,184,255,0.08);border:1px solid rgba(126,184,255,0.2);border-radius:10px;padding:14px;margin-bottom:14px;text-align:center';
808                banner.innerHTML = `
809                    <p style="color:var(--text);font-size:0.85rem;margin:0 0 10px">Your email hasn't been verified yet. Check your inbox or resend the link.</p>
810                    <button id="login-resend-btn" class="btn btn-outline btn-sm" style="font-size:0.8rem">Resend Verification Email</button>
811                `;
812                const tabs = card?.querySelector('.auth-tabs');
813                if (tabs) tabs.after(banner);
814                else card?.querySelector('.auth-logo')?.after(banner);
815                document.getElementById('login-resend-btn')?.addEventListener('click', async function() {
816                    this.disabled = true; this.textContent = 'Sending...';
817                    try {
818                        const resp = await fetch((window.PHANTOM_API_BASE || '') + '/api/verify-email', {
819                            method: 'POST',
820                            headers: { 'Content-Type': 'application/json' },
821                            body: JSON.stringify({ email: _verifyEmail }),
822                        });
823                        this.disabled = false; this.textContent = 'Resend Verification Email';
824                        if (resp.status === 429) Toast.error('Too many attempts. Please wait a few minutes.');
825                        else Toast.success('Verification email resent! Check your inbox.');
826                    } catch (e) {
827                        this.disabled = false; this.textContent = 'Resend Verification Email';
828                        Toast.error('Could not resend. Please wait a moment and try again.');
829                    }
830                });
831            } else if (error.message.includes('rate')) {
832                showError('Too many login attempts. Please wait a moment.');
833            } else {
834                showError('Login failed. Please try again.');
835            }
836            return;
837        }
838
839        // Store remember-me preference and register session-only handler if unchecked
840        const rememberMe = container.querySelector('#remember-me-check')?.checked ?? true;
841        localStorage.setItem('phantom-remember-me', rememberMe ? '1' : '0');
842        if (!rememberMe) {
843            window.addEventListener('beforeunload', function() {
844                supabase.auth.signOut();
845            }, { once: true });
846        }
847
848        // Clear demo mode and verification state
849        localStorage.removeItem('phantom-demo');
850        sessionStorage.removeItem('phantom-verify-email');
851        document.getElementById('demo-banner')?.remove();
852
853        // Offer biometric login for next time (mobile only)
854        if (window.PhantomBiometric) {
855            window.PhantomBiometric.promptEnableBiometric(email, password).catch(function() {});
856        }
857
858        // Fetch profile and cache it, then hydrate real data
859        await loadUserProfile(data.user.id);
860        await API.hydrateAll();
861
862        // Auto-redeem invite token if present
863        await _redeemInviteIfPresent(data.session.access_token);
864
865        Auth.login();
866        Toast.success('Welcome back, ' + (MOCK_USER.name || 'User') + '!');
867    }
868
869    // ── SIGNUP ──────────────────────────────────────────────────
870    async function handleSignup(e) {
871        e.preventDefault();
872        if (loading) return;
873
874        const form = e.target;
875        const name = form.querySelector('#signup-name').value.trim();
876        const email = form.querySelector('input[type="email"]').value.trim().toLowerCase();
877        const password = form.querySelector('input[type="password"]').value;
878        const acquisitionSource = form.querySelector('#acquisition-source')?.value || '';
879
880        if (!name || !email || !password) {
881            showError('Please fill in all fields.');
882            return;
883        }
884
885        if (password.length < 8) {
886            showError('Password must be at least 8 characters.');
887            return;
888        }
889
890        // CC-required 5-day trial: _requestedTier defaults to 'scout' for web signups
891        const _isNativeSignup = window.PHANTOM_NATIVE || false;
892        if (!_requestedTier && !_isNativeSignup && !_inviteToken) _requestedTier = 'scout';
893
894        setLoading(form, true);
895        showError('');
896
897        // Build signup metadata - include UTM params so handle_new_user() trigger can extract them
898        var _utmForMeta = {};
899        try { _utmForMeta = JSON.parse(localStorage.getItem('phantom-utm') || '{}'); } catch(e) {}
900        // Normalize UTM abbreviations to canonical signup_source values
901        var _utmSourceMap = { ig: 'instagram', fb: 'facebook_ad', meta: 'facebook_ad' };
902        // Determine acquisition source: user-picked source > UTM source (normalized) > 'direct'
903        var _rawUtmSource = _utmForMeta.utm_source ? (_utmSourceMap[_utmForMeta.utm_source] || _utmForMeta.utm_source) : null;
904        var _acqSource = acquisitionSource || _rawUtmSource || 'direct';
905        const signupMeta = {
906            name,
907            acquisition_source: _acqSource,
908            utm_source: _utmForMeta.utm_source || null,
909            utm_medium: _utmForMeta.utm_medium || null,
910            utm_campaign: _utmForMeta.utm_campaign || null,
911            utm_content: _utmForMeta.utm_content || null,
912            utm_term: _utmForMeta.utm_term || null,
913            referrer: localStorage.getItem('phantom-referrer') || document.referrer || null,
914        };
915        if (window.__TAURI_INTERNALS__) signupMeta.signup_platform = 'desktop';
916        else if (window.PHANTOM_NATIVE) signupMeta.signup_platform = 'ios';
917        else signupMeta.signup_platform = 'web';
918        const _dlRef = localStorage.getItem('phantom-download-ref');
919        if (_dlRef) signupMeta.download_ref = _dlRef;
920        if (_requestedTier) signupMeta.requested_tier = _requestedTier;
921        if (_requestedTier === 'custom') {
922            try {
923                const _storedMods = localStorage.getItem('phantom-selected-modules');
924                if (_storedMods) {
925                    const _parsedMods = JSON.parse(_storedMods);
926                    const _modArr = _parsedMods?.modules || (Array.isArray(_parsedMods) ? _parsedMods : null);
927                    if (Array.isArray(_modArr) && _modArr.length > 0) {
928                        signupMeta.pending_modules = JSON.stringify(_modArr);
929                    }
930                }
931            } catch (e) { /* silent */ }
932        }
933
934        // Build redirect URL with promo + plan so they survive cross-browser email verification
935        let _signupRedirect = window.PHANTOM_AUTH_REDIRECT || (window.location.origin + '/app/');
936        const _redirectParams = [];
937        const _savedPromo = localStorage.getItem('phantom-promo');
938        if (_savedPromo) _redirectParams.push('promo=' + encodeURIComponent(_savedPromo));
939        if (_requestedTier) _redirectParams.push('plan=' + encodeURIComponent(_requestedTier));
940        if (_redirectParams.length) _signupRedirect += (_signupRedirect.includes('?') ? '&' : '?') + _redirectParams.join('&');
941
942        const { data, error } = await supabase.auth.signUp({
943            email,
944            password,
945            options: {
946                data: signupMeta,
947                emailRedirectTo: _signupRedirect,
948            },
949        });
950
951        if (error) {
952            setLoading(form, false);
953            if (error.message.includes('already registered')) {
954                showError('Check your inbox for a verification link, or try logging in.');
955            } else if (error.message.includes('rate')) {
956                showError('Too many signup attempts. Please wait a moment.');
957            } else if (error.message.includes('password')) {
958                showError('Password is too weak. Use at least 8 characters.');
959            } else {
960                showError('Signup failed: ' + error.message);
961            }
962            console.error('Supabase signup error:', error);
963            return;
964        }
965
966        // Fire Meta Pixel CompleteRegistration + StartTrial events
967        if (typeof fbq === 'function') {
968            fbq('track', 'CompleteRegistration', {
969                content_name: 'Phantom Signup',
970                status: true,
971                currency: 'USD',
972                value: 0,
973                utm_source: _utmForMeta.utm_source || '',
974                utm_campaign: _utmForMeta.utm_campaign || '',
975            });
976            fbq('trackCustom', 'StartTrial', {
977                content_name: 'Trial Start',
978                value: 0,
979                currency: 'USD',
980            });
981        }
982
983        // Persist UTM data to Supabase (fire-and-forget)
984        // The handle_new_user() trigger now reads acquisition_source from auth metadata,
985        // but we also update here as a safety net (handles race conditions / trigger misses)
986        try {
987            var signupUserId = data.user?.id;
988            // Update acquisition_source + signup_source + UTM on profile (safety net - trigger should have set it already)
989            if (signupUserId) {
990                var _profileUtmUpdate = { acquisition_source: _acqSource, signup_source: _acqSource !== 'direct' ? _acqSource : null };
991                if (_utmForMeta.utm_source) _profileUtmUpdate.utm_source = _utmForMeta.utm_source;
992                if (_utmForMeta.utm_medium) _profileUtmUpdate.utm_medium = _utmForMeta.utm_medium;
993                if (_utmForMeta.utm_campaign) _profileUtmUpdate.utm_campaign = _utmForMeta.utm_campaign;
994                supabase.from('profiles').update(_profileUtmUpdate).eq('id', signupUserId).then(function(){}).catch(function(){});
995            }
996            // Insert marketing event with full UTM data (always, even for direct traffic)
997            if (signupUserId) {
998                supabase.from('marketing_events').insert({
999                    event_type: 'signup',
1000                    user_id: signupUserId,
1001                    email: email,
1002                    utm_source: _utmForMeta.utm_source || null,
1003                    utm_medium: _utmForMeta.utm_medium || null,
1004                    utm_campaign: _utmForMeta.utm_campaign || null,
1005                    utm_content: _utmForMeta.utm_content || null,
1006                    metadata: { name: name, tier: _requestedTier || 'unknown', source: _acqSource },
1007                }).then(function(){}).catch(function(){});
1008            }
1009        } catch(e) { /* silent */ }
1010
1011        // Fire GA4 signup event
1012        if (typeof gtag === 'function') {
1013            gtag('event', 'sign_up', {
1014                method: 'email',
1015                campaign: _utmForMeta.utm_campaign || '',
1016                source: _utmForMeta.utm_source || '',
1017                content: _utmForMeta.utm_content || '',
1018            });
1019        }
1020
1021        // Affiliate referral attribution (fire-and-forget)
1022        var refCode = localStorage.getItem('phantom-ref');
1023        // Enforce 90-day cookie window
1024        var refTs = parseInt(localStorage.getItem('phantom-ref-ts') || '0');
1025        if (refCode && refTs && (Date.now() - refTs) > 90 * 86400000) {
1026            localStorage.removeItem('phantom-ref');
1027            localStorage.removeItem('phantom-ref-ts');
1028            refCode = null;
1029        }
1030        if (refCode && data.session) {
1031            fetch((window.PHANTOM_API_BASE || '') + '/api/affiliate-track', {
1032                method: 'POST',
1033                headers: {
1034                    'Content-Type': 'application/json',
1035                    'Authorization': 'Bearer ' + data.session.access_token,
1036                },
1037                body: JSON.stringify({ ref_code: refCode, ref_ts: refTs }),
1038            }).then(function(){ localStorage.removeItem('phantom-ref'); localStorage.removeItem('phantom-ref-ts'); }).catch(function(){});
1039        }
1040
1041        // Check if email confirmation is required
1042        if (data.user && !data.session) {
1043            // Detect repeat signup of already-confirmed user:
1044            // Supabase returns user with empty identities[] for security
1045            if (data.user.identities && data.user.identities.length === 0) {
1046                setLoading(form, false);
1047                showError('');
1048                const card = container.querySelector('.auth-card');
1049                card?.querySelector('.auth-existing-banner')?.remove();
1050                const existBanner = document.createElement('div');
1051                existBanner.className = 'auth-existing-banner';
1052                existBanner.style.cssText = 'background:rgba(126,184,255,0.08);border:1px solid rgba(126,184,255,0.2);border-radius:10px;padding:14px;margin-bottom:14px;text-align:center';
1053                existBanner.innerHTML = '<p style="color:var(--text);font-size:0.85rem;margin:0 0 10px">This email is already registered.</p>'
1054                    + '<button class="btn btn-primary btn-sm" id="existing-goto-login" style="margin-right:8px;font-size:0.8rem">Log In</button>'
1055                    + '<button class="btn btn-outline btn-sm" id="existing-reset-pw" style="font-size:0.8rem">Reset Password</button>';
1056                const tabs = card?.querySelector('.auth-tabs');
1057                if (tabs) tabs.after(existBanner);
1058                else card?.querySelector('.auth-logo')?.after(existBanner);
1059                document.getElementById('existing-goto-login')?.addEventListener('click', () => { mode = 'login'; errorMsg = ''; successMsg = ''; render(); });
1060                document.getElementById('existing-reset-pw')?.addEventListener('click', () => { mode = 'reset'; errorMsg = ''; successMsg = ''; render(); });
1061                return;
1062            }
1063            // Genuine new signup - email confirmation required
1064            // Send branded confirmation email via Resend (fire-and-forget)
1065            fetch((window.PHANTOM_API_BASE || '') + '/api/verify-email', {
1066                method: 'POST',
1067                headers: { 'Content-Type': 'application/json' },
1068                body: JSON.stringify({ email, name }),
1069            }).catch(function(){});
1070            _verifyEmail = email;
1071            sessionStorage.setItem('phantom-verify-email', email);
1072            // If plan was pre-selected (from pricing page), save it for billing page after verification
1073            if (_requestedTier) {
1074                localStorage.setItem('phantom-selected-plan', JSON.stringify({ plan: _requestedTier, ts: Date.now() }));
1075            }
1076            mode = 'verify';
1077            successMsg = '';
1078            errorMsg = '';
1079            render();
1080        } else if (data.session) {
1081            // Auto-confirmed (e.g., if email confirmation disabled in Supabase)
1082            sessionStorage.removeItem('phantom-verify-email');
1083            await loadUserProfile(data.user.id);
1084            await API.hydrateAll();
1085            API.sendWelcomeEmail(name); // fire-and-forget
1086            _notifyAdminSignup(); // fire-and-forget admin notification
1087            // Auto-redeem invite token if present
1088            await _redeemInviteIfPresent(data.session.access_token);
1089            // Plan selection happens on billing page after login (not during signup)
1090            // If plan was pre-selected from pricing page, save it for billing auto-checkout
1091            if (_requestedTier) {
1092                localStorage.setItem('phantom-selected-plan', JSON.stringify({ plan: _requestedTier, ts: Date.now() }));
1093            }
1094            Auth.login();
1095            Toast.success(_inviteToken ? 'Welcome to Phantom! Your account is ready.' : 'Welcome to Phantom! Your 5-day free trial just started.');
1096        }
1097    }
1098
1099    // ── INVITE TOKEN REDEMPTION ─────────────────────────────────
1100    async function _redeemInviteIfPresent(accessToken) {
1101        const token = localStorage.getItem('phantom-invite-token');
1102        if (!token) return;
1103        try {
1104            const res = await fetch((window.PHANTOM_API_BASE || '') + '/api/invite', {
1105                method: 'POST',
1106                headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + accessToken },
1107                body: JSON.stringify({ action: 'redeem', token }),
1108            });
1109            const result = await res.json();
1110            if (result.success) {
1111                localStorage.removeItem('phantom-invite-token');
1112                // Reload profile to pick up admin_granted status
1113                const { data: { user } } = await supabase.auth.getUser();
1114                if (user) await loadUserProfile(user.id);
1115                Toast.success('Invite redeemed - you have free access!');
1116            } else {
1117                // Token invalid/expired/already used - clear it silently
1118                localStorage.removeItem('phantom-invite-token');
1119            }
1120        } catch (e) {
1121            console.error('[Auth] Invite redeem failed:', e);
1122        }
1123    }
1124
1125    // ── ADMIN SIGNUP NOTIFICATION (fire-and-forget) ──
1126    // Server-side signup_notified_at column is the authoritative dedup guard.
1127    function _notifyAdminSignup() {
1128        try {
1129            const user = Store.getUser();
1130            if (!user || !user.id) return;
1131            const key = `phantom-signup-notified-${user.id}`;
1132            if (localStorage.getItem(key)) return;
1133            localStorage.setItem(key, '1');
1134            supabase.auth.getSession().then(({ data }) => {
1135                if (!data.session) return;
1136                fetch((window.PHANTOM_API_BASE || '') + '/api/signup-notify', {
1137                    method: 'POST',
1138                    headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + data.session.access_token },
1139                    body: JSON.stringify({
1140                        name: user.name || '',
1141                        email: user.email || '',
1142                        tier: user.tier || 'trial',
1143                        source: user.acquisition_source || '',
1144                    }),
1145                }).catch(() => {});
1146            }).catch(() => {});
1147        } catch (e) { /* silent */ }
1148    }
1149
1150    // ── PASSWORD RESET ──────────────────────────────────────────
1151    async function handleReset(e) {
1152        e.preventDefault();
1153        if (loading) return;
1154
1155        const form = e.target;
1156        const email = form.querySelector('input[type="email"]').value.trim().toLowerCase();
1157
1158        if (!email) {
1159            showError('Please enter your email.');
1160            return;
1161        }
1162
1163        setLoading(form, true);
1164        showError('');
1165
1166        try {
1167            const apiBase = window.PHANTOM_API_BASE || '';
1168            const res = await fetch(apiBase + '/api/forgot-password', {
1169                method: 'POST',
1170                headers: { 'Content-Type': 'application/json' },
1171                body: JSON.stringify({ email }),
1172            });
1173
1174            if (res.status === 429) {
1175                showError('Too many attempts. Please wait a few minutes and try again.');
1176                return;
1177            }
1178
1179            if (!res.ok) {
1180                showError('Something went wrong. Please try again.');
1181                return;
1182            }
1183
1184            successMsg = 'If an account exists with that email, you\'ll receive a reset link shortly. (Check spam too.)';
1185            errorMsg = '';
1186            render();
1187        } catch (err) {
1188            console.error('[Auth] forgot-password request failed:', err);
1189            showError('Something went wrong. Please try again.');
1190        } finally {
1191            setLoading(form, false);
1192        }
1193    }
1194
1195    // ── RENDER HELPERS ──────────────────────────────────────────
1196
1197    // Set to true once Google OAuth verification is approved
1198    const GOOGLE_OAUTH_ENABLED = true;
1199
1200    function renderLogin() {
1201        if (!GOOGLE_OAUTH_ENABLED) {
1202            return `
1203            <form id="login-form" class="auth-form">
1204                <div class="form-group">
1205                    <label class="form-label">Email</label>
1206                    <input type="email" class="form-input" placeholder="[email protected]" required autocomplete="email">
1207                </div>
1208                <div class="form-group">
1209                    <label class="form-label">Password</label>
1210                    <input type="password" class="form-input" placeholder="Enter password" required autocomplete="current-password">
1211                </div>
1212                <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
1213                    <input type="checkbox" id="remember-me-check" checked style="width:16px;height:16px;accent-color:var(--accent);cursor:pointer;flex-shrink:0">
1214                    <label for="remember-me-check" class="text-sm" style="cursor:pointer;color:var(--text-secondary);user-select:none">Remember me</label>
1215                </div>
1216                <button type="submit" class="btn btn-primary btn-block btn-lg">Log In</button>
1217                <div style="text-align:center;margin:12px 0"><span class="text-xs text-muted">or</span></div>
1218                <button type="button" class="btn btn-outline btn-block" id="demo-login-btn">Try Live Demo</button>
1219                <div class="auth-footer" style="display:flex;flex-direction:column;gap:6px;align-items:center">
1220                    <a href="#" data-forgot>Forgot your password?</a>
1221                    <span class="text-sm">Don't have an account? <a href="#" data-mode="signup" class="auth-tab-link" style="color:var(--accent);font-weight:600">Sign up</a></span>
1222                </div>
1223            </form>`;
1224        }
1225        const _googleBtn = `<button type="button" id="google-auth-btn" style="width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:12px 20px;border-radius:10px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.06);color:var(--text);font-size:0.92rem;font-weight:600;cursor:pointer;transition:all 0.2s ease;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)" onmouseover="this.style.background='rgba(255,255,255,0.1)';this.style.borderColor='rgba(255,255,255,0.2)'" onmouseout="this.style.background='rgba(255,255,255,0.06)';this.style.borderColor='rgba(255,255,255,0.12)'">
1226            <img src="../assets/google-g-logo.png" width="20" height="20" alt="G" style="flex-shrink:0">
1227            <span>Continue with Google</span>
1228        </button>`;
1229        const _appleBtn = window.PHANTOM_NATIVE ? `<button type="button" id="apple-auth-btn" style="width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:12px 20px;border-radius:10px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.06);color:var(--text);font-size:0.92rem;font-weight:600;cursor:pointer;transition:all 0.2s ease;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)" onmouseover="this.style.background='rgba(255,255,255,0.1)';this.style.borderColor='rgba(255,255,255,0.2)'" onmouseout="this.style.background='rgba(255,255,255,0.06)';this.style.borderColor='rgba(255,255,255,0.12)'">
1230            <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/></svg>
1231            <span>Sign in with Apple</span>
1232        </button>` : '';
1233        return `
1234            <div id="auth-picker">
1235                ${_appleBtn}
1236                ${_googleBtn}
1237                <div style="display:flex;align-items:center;gap:12px;margin:14px 0">
1238                    <div style="flex:1;height:1px;background:var(--glass-border)"></div>
1239                    <span style="font-size:0.78rem;color:var(--text-muted)">or</span>
1240                    <div style="flex:1;height:1px;background:var(--glass-border)"></div>
1241                </div>
1242                <button type="button" id="email-auth-btn" style="width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:12px 20px;border-radius:10px;border:1px solid var(--glass-border);background:rgba(255,255,255,0.04);color:var(--text);font-size:0.92rem;font-weight:600;cursor:pointer;transition:all 0.2s ease">
1243                    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
1244                    Continue with Email
1245                </button>
1246                <div class="auth-footer" style="text-align:center;margin-top:16px">
1247                    <span class="text-sm">Don't have an account? <a href="#" data-mode="signup" class="auth-tab-link" style="color:var(--accent);font-weight:600">Sign up</a></span>
1248                </div>
1249            </div>
1250            <div id="auth-email-form" style="display:none">
1251                <button type="button" id="back-to-picker" style="display:flex;align-items:center;gap:6px;background:none;border:none;color:var(--text-muted);font-size:0.82rem;cursor:pointer;padding:0;margin-bottom:14px;transition:color 0.2s" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--text-muted)'">
1252                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
1253                    Back
1254                </button>
1255                <form id="login-form" class="auth-form">
1256                    <div class="form-group">
1257                        <label class="form-label">Email</label>
1258                        <input type="email" class="form-input" placeholder="[email protected]" required autocomplete="email">
1259                    </div>
1260                    <div class="form-group">
1261                        <label class="form-label">Password</label>
1262                        <input type="password" class="form-input" placeholder="Enter password" required autocomplete="current-password">
1263                    </div>
1264                    <button type="submit" class="btn btn-primary btn-block btn-lg">Log In</button>
1265                    <div style="text-align:center;margin:12px 0"><span class="text-xs text-muted">or</span></div>
1266                    <button type="button" class="btn btn-outline btn-block" id="demo-login-btn">Try Live Demo</button>
1267                    <div class="auth-footer" style="display:flex;flex-direction:column;gap:6px;align-items:center">
1268                        <a href="#" data-forgot>Forgot your password?</a>
1269                        <span class="text-sm">Don't have an account? <a href="#" data-mode="signup" class="auth-tab-link" style="color:var(--accent);font-weight:600">Sign up</a></span>
1270                    </div>
1271                </form>
1272            </div>`;
1273    }
1274
1275    function renderSignup() {
1276        const tierNames = { scout: 'Scout', hunter: 'Hunter', operator: 'Operator' };
1277        const tierPrices = { starter: 49, scout: 149, hunter: 349, operator: 697 };
1278        const tierDescs = { scout: 'Full system with 500 monthly leads', hunter: 'Scale with 2,000 leads and team support', operator: 'Complete agency suite with 3,500 leads' };
1279        const tierFeatures = {
1280            scout:    { leads: '500',   creatives: '100',   outreach: '1,000',     deals: '50' },
1281            hunter:   { leads: '2,000', creatives: '500',   outreach: '5,000',     deals: '200' },
1282            operator: { leads: '3,500', creatives: '2,000', outreach: 'Unlimited', deals: 'Unlimited' },
1283        };
1284        const isNative = window.PHANTOM_NATIVE || false;
1285        // On native iOS: CC-required trials cannot be collected in-app (Apple 3.1.1).
1286        // Show a web-redirect message instead of the signup form. Login still works natively.
1287        if (isNative) {
1288            return `
1289                <div style="text-align:center;padding:24px 0">
1290                    <div style="font-size:2.5rem;margin-bottom:16px">${typeof ICONS !== 'undefined' && ICONS.link ? ICONS.link : '&#128279;'}</div>
1291                    <h3 style="margin-bottom:8px;color:var(--text)">Sign Up on the Web</h3>
1292                    <p class="text-sm text-secondary" style="margin-bottom:20px;line-height:1.6;max-width:320px;margin-left:auto;margin-right:auto">
1293                        To start your 5-day free trial, sign up at phantomleads.ai in your browser. Then come back here to log in.
1294                    </p>
1295                    <a href="https://phantomleads.ai/app/#/login?action=signup" style="display:inline-block;padding:12px 28px;border-radius:10px;background:var(--accent);color:#fff;font-weight:600;font-size:0.92rem;text-decoration:none">Go to phantomleads.ai</a>
1296                    <div class="auth-footer" style="text-align:center;margin-top:20px">
1297                        <span class="text-sm">Already have an account? <a href="#" data-mode="login" class="auth-tab-link" style="color:var(--accent);font-weight:600">Log in</a></span>
1298                    </div>
1299                </div>`;
1300        }
1301        const _savedPromo = localStorage.getItem('phantom-promo') || '';
1302        const _isPromoSignup = !!_savedPromo;
1303        // Apply promo discount to displayed prices (visual only - Stripe enforces real discount)
1304        const _SIGNUP_PROMO_DISCOUNTS = { 'CLOSEFRIENDS30': 0.30 };
1305        const _signupPromoDiscount = _savedPromo ? (_SIGNUP_PROMO_DISCOUNTS[_savedPromo] || 0) : 0;
1306        const _hasKnownPromo = _signupPromoDiscount > 0;
1307        // Build custom plan banner for modular build page signups
1308        let _customPlanBanner = '';
1309        if (_requestedTier === 'custom') {
1310            try {
1311                const _rawMods = localStorage.getItem('phantom-selected-modules');
1312                if (_rawMods) {
1313                    const _parsedMods = JSON.parse(_rawMods);
1314                    const _mods = _parsedMods?.modules || (Array.isArray(_parsedMods) ? _parsedMods : []);
1315                    const _total = _parsedMods?.total || _mods.reduce((s, m) => s + (m.price || 0), 0);
1316                    const _modLabels = { 'lead-finder': 'Lead Finder', 'outreach': 'Outreach', 'creatives': 'Creative Studio', 'pipeline': 'Pipeline', 'chat': 'AI Chat', 'content': 'Content AI', 'marketplace': 'Marketplace' };
1317                    const _names = _mods.slice(0, 3).map(m => _modLabels[m.id] || m.id).join(', ') + (_mods.length > 3 ? ` +${_mods.length - 3} more` : '');
1318                    _customPlanBanner = `<div style="background:rgba(126,184,255,0.08);border:1px solid rgba(126,184,255,0.2);border-radius:10px;padding:12px 14px;margin-bottom:14px;text-align:center">
1319                        <span style="font-size:0.95rem;font-weight:600;color:var(--accent)">Your Custom Plan - $${_total}/mo</span><br>
1320                        <span class="text-xs text-muted">${_names}</span><br>
1321                        <span class="text-xs" style="color:#4CAF50;margin-top:2px;display:block">5-day free trial included - cancel anytime</span>
1322                    </div>`;
1323                }
1324            } catch(e) { /* silent */ }
1325            if (!_customPlanBanner) {
1326                _customPlanBanner = `<div style="background:rgba(126,184,255,0.08);border:1px solid rgba(126,184,255,0.2);border-radius:10px;padding:12px 14px;margin-bottom:14px;text-align:center">
1327                    <span style="font-size:0.95rem;font-weight:600;color:var(--accent)">Custom Modular Plan</span><br>
1328                    <span class="text-xs" style="color:#4CAF50">5-day free trial included - cancel anytime</span>
1329                </div>`;
1330            }
1331        }
1332
1333        // Signup banner: invite info or promo code only. Plan selection happens on billing page after login.
1334        const tierSignupBanner = _inviteToken
1335            ? `<div style="background:rgba(76,175,80,0.08);border:1px solid rgba(76,175,80,0.3);border-radius:10px;padding:12px 14px;margin-bottom:14px;text-align:center">
1336                <span style="font-size:0.95rem;font-weight:600;color:#4CAF50">You have been invited!</span><br>
1337                <span class="text-xs text-muted">Create your account to get free access to Phantom</span>
1338               </div>`
1339            : _isPromoSignup ? `<div style="background:linear-gradient(135deg, rgba(76,175,80,0.12), rgba(126,184,255,0.08));border:1.5px solid rgba(76,175,80,0.3);border-radius:10px;padding:12px 14px;text-align:center;margin-bottom:14px">
1340                <div style="font-weight:700;font-size:1rem;color:#4CAF50">${_hasKnownPromo ? Math.round(_signupPromoDiscount * 100) + '% OFF - Locked In Forever' : 'Promo Code Applied'}</div>
1341                <div class="text-xs text-muted">Code <strong>${_savedPromo}</strong> ${_hasKnownPromo ? 'applied - founding member pricing' : 'will be applied at checkout'}</div>
1342            </div>` : '';
1343
1344        const sources = [
1345            { id: 'instagram', label: 'Instagram' },
1346            { id: 'facebook_ad', label: 'Facebook / Meta Ad' },
1347            { id: 'google', label: 'Google Search' },
1348            { id: 'friend_referral', label: 'Friend or Referral' },
1349            { id: 'tiktok', label: 'TikTok' },
1350            { id: 'youtube', label: 'YouTube' },
1351            { id: 'other', label: 'Other' },
1352        ];
1353
1354        if (!GOOGLE_OAUTH_ENABLED) {
1355            return `
1356            ${tierSignupBanner}
1357            <form id="signup-form" class="auth-form">
1358                <div class="form-group">
1359                    <label class="form-label">Full Name</label>
1360                    <input type="text" class="form-input" id="signup-name" placeholder="Your name" required autocomplete="name">
1361                </div>
1362                <div class="form-group">
1363                    <label class="form-label">Email</label>
1364                    <input type="email" class="form-input" placeholder="[email protected]" required autocomplete="email">
1365                </div>
1366                <div class="form-group">
1367                    <label class="form-label">Password</label>
1368                    <input type="password" class="form-input" placeholder="Minimum 8 characters" required minlength="8" autocomplete="new-password">
1369                </div>
1370                <input type="hidden" name="acquisition_source" id="acquisition-source" value="">
1371                <button type="submit" class="btn btn-primary btn-block btn-lg">Create Account</button>
1372                <div style="text-align:center;margin-top:8px">
1373                    <span class="text-xs text-muted">${_inviteToken ? 'Create your account - no payment required' : isNative ? 'Sign up at phantomleads.ai to start your trial' : 'Start your 5-day free trial. No credit card required.'}</span>
1374                </div>
1375                <div class="auth-footer" style="text-align:center;margin-top:10px">
1376                    <span class="text-sm">Already have an account? <a href="#" data-mode="login" class="auth-tab-link" style="color:var(--accent);font-weight:600">Log in</a></span>
1377                </div>
1378            </form>`;
1379        }
1380
1381        const _googleBtn = `<button type="button" id="google-auth-btn" style="width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:12px 20px;border-radius:10px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.06);color:var(--text);font-size:0.92rem;font-weight:600;cursor:pointer;transition:all 0.2s ease;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)" onmouseover="this.style.background='rgba(255,255,255,0.1)';this.style.borderColor='rgba(255,255,255,0.2)'" onmouseout="this.style.background='rgba(255,255,255,0.06)';this.style.borderColor='rgba(255,255,255,0.12)'">
1382            <img src="../assets/google-g-logo.png" width="20" height="20" alt="G" style="flex-shrink:0">
1383            <span>Continue with Google</span>
1384        </button>`;
1385        const _appleBtn = window.PHANTOM_NATIVE ? `<button type="button" id="apple-auth-btn" style="width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:12px 20px;border-radius:10px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.06);color:var(--text);font-size:0.92rem;font-weight:600;cursor:pointer;transition:all 0.2s ease;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)" onmouseover="this.style.background='rgba(255,255,255,0.1)';this.style.borderColor='rgba(255,255,255,0.2)'" onmouseout="this.style.background='rgba(255,255,255,0.06)';this.style.borderColor='rgba(255,255,255,0.12)'">
1386            <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/></svg>
1387            <span>Sign in with Apple</span>
1388        </button>` : '';
1389        return `
1390            ${tierSignupBanner}
1391            <div id="auth-picker">
1392                ${_appleBtn}
1393                ${_googleBtn}
1394                <div style="display:flex;align-items:center;gap:12px;margin:14px 0">
1395                    <div style="flex:1;height:1px;background:var(--glass-border)"></div>
1396                    <span style="font-size:0.78rem;color:var(--text-muted)">or sign up with email</span>
1397                    <div style="flex:1;height:1px;background:var(--glass-border)"></div>
1398                </div>
1399            </div>
1400            <div id="auth-email-form">
1401                <form id="signup-form" class="auth-form">
1402                    <div class="form-group">
1403                        <label class="form-label">Full Name</label>
1404                        <input type="text" class="form-input" id="signup-name" placeholder="Your name" required autocomplete="name">
1405                    </div>
1406                    <div class="form-group">
1407                        <label class="form-label">Email</label>
1408                        <input type="email" class="form-input" placeholder="[email protected]" required autocomplete="email">
1409                    </div>
1410                    <div class="form-group">
1411                        <label class="form-label">Password</label>
1412                        <input type="password" class="form-input" placeholder="Minimum 8 characters" required minlength="8" autocomplete="new-password">
1413                    </div>
1414                    <input type="hidden" name="acquisition_source" id="acquisition-source" value="">
1415                    <button type="submit" class="btn btn-primary btn-block btn-lg">Create Account</button>
1416                    <div style="text-align:center;margin-top:8px">
1417                        <span class="text-xs text-muted">${_inviteToken ? 'Create your account - no payment required' : isNative ? 'Sign up at phantomleads.ai to start your trial' : 'Start your 5-day free trial. No credit card required.'}</span>
1418                    </div>
1419                    <div class="auth-footer" style="text-align:center;margin-top:10px">
1420                        <span class="text-sm">Already have an account? <a href="#" data-mode="login" class="auth-tab-link" style="color:var(--accent);font-weight:600">Log in</a></span>
1421                    </div>
1422                </form>
1423            </div>`;
1424    }
1425
1426    function renderReset() {
1427        return `
1428            <form id="reset-form" class="auth-form">
1429                <h3 style="margin-bottom:8px;text-align:center">Reset Password</h3>
1430                <p class="text-sm text-secondary" style="text-align:center;margin-bottom:16px">Enter your email and we'll send you a reset link.</p>
1431                <div class="form-group">
1432                    <label class="form-label">Email</label>
1433                    <input type="email" class="form-input" placeholder="[email protected]" required autocomplete="email">
1434                </div>
1435                <button type="submit" class="btn btn-primary btn-block btn-lg">Send Reset Link</button>
1436                <div class="auth-footer">
1437                    <a href="#" data-back-login>Back to login</a>
1438                </div>
1439            </form>`;
1440    }
1441
1442    function renderVerify() {
1443        // Mask email: j***[email protected]
1444        let maskedEmail = '';
1445        if (_verifyEmail) {
1446            const [local, domain] = _verifyEmail.split('@');
1447            if (local.length <= 2) maskedEmail = local + '***@' + domain;
1448            else maskedEmail = local[0] + '***' + local[local.length - 1] + '@' + domain;
1449        }
1450        return `
1451            <div class="auth-form" style="text-align:center;padding:20px 0">
1452                <div style="font-size:3rem;margin-bottom:16px">&#9993;</div>
1453                <h3 style="margin-bottom:8px">Check Your Email</h3>
1454                <p class="text-sm text-secondary" style="margin-bottom:20px;line-height:1.6">
1455                    We've sent a verification link${maskedEmail ? ' to <strong>' + sanitize(maskedEmail) + '</strong>' : ' to your email'}.<br>
1456                    Click the link to activate your account.
1457                </p>
1458                ${_verifyEmail ? '<button class="btn btn-outline btn-block" id="resend-verify-btn" style="margin-bottom:10px">Resend Email</button>' : ''}
1459                <button class="btn btn-primary btn-block" data-back-login style="margin-bottom:8px">Already Verified? Log In</button>
1460                <p class="text-xs text-muted" style="margin-top:6px">If you already clicked the verification link, go ahead and log in.</p>
1461            </div>`;
1462    }
1463
1464    render();
1465}

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.