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)\'">← 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 : '🔗'}</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">✉</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.