1/* global escapeHtml, obEmoji, obToggleInterest */ 2// Login/registration overlay logic. 3// On load, checks /auth/me; shows the overlay (covering the app) unless the 4// user has a valid session. Login/register reload the page on success so the 5// app re-initializes cleanly with the session in place. 6(function () { 7 const APP_LANG_KEY = 'soulor-ai_default_lang'; 8 const LEGACY_LANG_KEY = 'soulor_landing_lang'; 9 const COUNTRY_CODE_KEY = 'soulor-ai_country_code'; 10 const ALLOWED_LANGS = new Set(['en', 'zh', 'es', 'ja', 'ko', 'fr']); 11 const AUTH_COPY_FALLBACKS = { 12 zh: { 13 'Welcome': '欢è¿åæ¥', 14 'Log in or create an account to continue': 'ç»å½æå建账å·åç»§ç»', 15 'Email': 'é®ç®±', 16 'Phone': 'ææºå·', 17 'Password': 'å¯ç ', 18 'Confirm password': '确认å¯ç ', 19 'Forgot your password?': 'å¿è®°å¯ç äºï¼', 20 'Reset your password': 'éç½®å¯ç ', 21 'Enter the email address for your account, then send a reset code': 'è¾å ¥ä½ è´¦å·çé®ç®±å°åï¼ç¶ååééç½®éªè¯ç ', 22 'New password': 'æ°å¯ç ', 23 'Confirm new password': '确认æ°å¯ç ', 24 'Reset password': 'éç½®å¯ç ', 25 'Back to login': 'è¿åç»å½', 26 'Log in': 'ç»å½', 27 'or': 'æ', 28 'Create account': 'å建账å·', 29 'Continue with Google': 'ä½¿ç¨ Google ç»§ç»', 30 'Phone auth is not available yet': 'ææºå·ç»å½ææªå¼æ¾', 31 'Phone number': 'ææºå·', 32 'Country code': 'å½å®¶åºå·', 33 'Private memory': 'ç§å¯è®°å¿',
34 'Keep your companion context across chats': '跨对è¯ä¿çä½ çéªä¼´ä¸ä¸æ', 35 'Fast verification': 'å¿«ééªè¯', 36 'Email, SMS, and WhatsApp signup flows': 'æ¯æé®ç®±ãçä¿¡å WhatsApp 注åéªè¯', 37 }, 38 }; 39 function tr(text) { return window.t ? window.t(text) : text; } 40 function el(id) { return document.getElementById(id); } 41 function show() { const o = el('authOverlay'); if (o) o.style.display = 'flex'; } 42 function hide() { const o = el('authOverlay'); if (o) o.style.display = 'none'; } 43 function authText(text) { 44 const lang = getDefaultLang(); 45 if (window.t) return window.t(text); 46 return AUTH_COPY_FALLBACKS[lang]?.[text] || text; 47 } 48 function syncStaticAuthCopy() { 49 const title = document.querySelector('.auth-title'); 50 const sub = document.querySelector('.auth-sub'); 51 const emailBtn = el('contactEmailBtn'); 52 const phoneBtn = el('contactPhoneBtn'); 53 const phoneInput = el('authPhone'); 54 const countryCode = el('authCountryCode'); 55 const pass = el('authPass'); 56 const confirmPass = el('authConfirmPass'); 57 const forgot = el('forgotPasswordLink'); 58 const loginBtn = el('loginBtn'); 59 const registerBtn = el('registerBtn'); 60 const forgotResetBtn = el('forgotResetBtn'); 61 const backToLoginLink = el('backToLoginLink'); 62 const dividers = document.querySelectorAll('.auth-divider span'); 63 document.querySelectorAll('.auth-left-chip strong, .auth-left-chip span').forEach(node => { 64 const raw = String(node.dataset.i18n || node.textContent || '').trim(); 65 if (!raw) return; 66 if (!node.dataset.i18n) node.dataset.i18n = raw; 67 node.textContent = authText(node.dataset.i18n); 68 }); 69 if (title) title.textContent = authText('Welcome'); 70 if (sub) sub.textContent = authText('Log in or create an account to continue'); 71 if (emailBtn) emailBtn.textContent = authText('Email'); 72 if (phoneBtn) phoneBtn.textContent = authText('Phone'); 73 if (phoneInput) phoneInput.placeholder = authText('Phone number'); 74 if (countryCode) countryCode.setAttribute('aria-label', authText('Country code')); 75 if (pass) pass.placeholder = authText('Password'); 76 if (confirmPass) confirmPass.placeholder = authText('Confirm password'); 77 if (forgot) forgot.textContent = authText('Forgot your password?'); 78 if (loginBtn) loginBtn.textContent = authText('Log in'); 79 if (registerBtn) registerBtn.textContent = authText('Create account'); 80 const googleLabel = el('googleAuthLabel'); 81 if (googleLabel) googleLabel.textContent = authText('Continue with Google'); 82 if (phoneBtn) phoneBtn.title = authText('Phone auth is not available yet'); 83 if (forgotResetBtn) forgotResetBtn.textContent = authText('Reset password'); 84 if (backToLoginLink) backToLoginLink.textContent = authText('Back to login'); 85 dividers.forEach(d => { d.textContent = authText('or'); }); 86 } 87 function setError(msg) { 88 const e = el('authError'); 89 if (e) e.textContent = msg || ''; 90 const n = el('authNote'); 91 if (n) { 92 n.textContent = ''; 93 delete n.dataset.noteState; 94 } 95 } 96 function setNote(msg, state = '') { 97 const n = el('authNote'); 98 if (n) { 99 n.textContent = msg || ''; 100 if (state) n.dataset.noteState = state; 101 else delete n.dataset.noteState; 102 } 103 const e = el('authError'); 104 if (e) e.textContent = ''; 105 } 106 let _isBusy = false; 107 function setBusy(b) { 108 _isBusy = Boolean(b); 109 document.querySelectorAll('.auth-btn').forEach(x => { x.disabled = _isBusy; }); 110 updateCodeSendButton(); 111 } 112 function normalizeLang(lang) { 113 const value = String(lang || '').trim().toLowerCase(); 114 return ALLOWED_LANGS.has(value) ? value : ''; 115 } 116 function getUrlLang() { 117 try { 118 return normalizeLang(new URLSearchParams(window.location.search).get('lang')); 119 } catch { 120 return ''; 121 } 122 } 123 function getBrowserLang() { 124 const candidates = Array.isArray(navigator.languages) && navigator.languages.length 125 ? navigator.languages 126 : [navigator.language || '']; 127 for (const candidate of candidates) { 128 const match = normalizeLang(String(candidate || '').split('-')[0]); 129 if (match) return match; 130 } 131 return ''; 132 } 133 function getDefaultLang() { 134 const urlLang = getUrlLang(); 135 if (urlLang) { 136 localStorage.setItem(APP_LANG_KEY, urlLang); 137 localStorage.setItem(LEGACY_LANG_KEY, urlLang); 138 return urlLang; 139 } 140 const appLang = normalizeLang(localStorage.getItem(APP_LANG_KEY)); 141 if (appLang) { 142 if (normalizeLang(localStorage.getItem(LEGACY_LANG_KEY)) !== appLang) { 143 localStorage.setItem(LEGACY_LANG_KEY, appLang); 144 } 145 return appLang; 146 } 147 const landingLang = normalizeLang(localStorage.getItem(LEGACY_LANG_KEY)); 148 if (landingLang) { 149 localStorage.setItem(APP_LANG_KEY, landingLang); 150 return landingLang; 151 } 152 return getBrowserLang() || 'en'; 153 } 154 155 // The Google callback lands on /app?welcome=1 (new account -> onboarding) or 156 // /app?auth_error=... (shown on the overlay). Strip them so a reload doesn't 157 // replay either. 158 function consumeOAuthParams() { 159 let params; 160 try { params = new URLSearchParams(window.location.search); } catch { return; } 161 const err = params.get('auth_error'); 162 const welcome = params.get('welcome') === '1'; 163 if (!err && !welcome) return; 164 if (welcome) sessionStorage.setItem('soulor_new_user', '1'); 165 if (err) setError(err); 166 params.delete('auth_error'); 167 params.delete('welcome'); 168 const qs = params.toString(); 169 history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash); 170 } 171 172 async function check() { 173 try { 174 const r = await fetch('/auth/me'); 175 if (r.ok) { 176 hide(); 177 const me = await r.json().catch(() => ({})); 178 if (me.user?.email) window.__soularUserEmail = me.user.email; 179 window._csrf = me.csrfToken || document.cookie.match(/(?:^|;\s*)csrf=([^;
179]+)/)?.[1] || ''; 180 window.loadProfileFromServer?.(); 181 // Load subscription in background and update plan card 182 if (window.BillingService) { 183 window.BillingService.loadSubscription().then(updatePlanCard); 184 } else { 185 window.addEventListener('billing-ready', () => 186 window.BillingService.loadSubscription().then(updatePlanCard), { once: true }); 187 } 188 if (sessionStorage.getItem('soulor_new_user') === '1') { 189 sessionStorage.removeItem('soulor_new_user'); 190 showOnboarding(); 191 } 192 window.dispatchEvent(new Event('auth-ready')); 193 return true; 194 } 195 } catch { /* offline / server down â fall through to overlay */ } 196 show(); 197 return false; 198 } 199 200 function updatePlanCard(sub) { 201 const badge = document.getElementById('planBadge'); 202 const renew = document.getElementById('planRenewDate'); 203 const btn = document.getElementById('planActionBtn'); 204 if (!badge || !btn) return; 205 if (sub?.isPro) { 206 window.applyProBadgeStyle(badge); 207 if (sub.currentPeriodEnd && renew) { 208 renew.textContent = tr('Renews') + ' ' + new Date(sub.currentPeriodEnd * 1000).toLocaleDateString(); 209 } 210 btn.textContent = tr('Manage'); 211 btn.onclick = () => window.BillingService?.openPortal(); 212 } 213 } 214 215 let _otpEmail = ''; 216 let _otpPhone = ''; 217 let _otpMode = ''; 218 let _contactMode = 'email'; 219 let _deliveryMode = 'sms'; 220 let _authMode = 'login'; 221 let _otpCooldownSeconds = 0; 222 let _otpTimerInterval = null; 223 224 function isValidEmail(value) { 225 return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(value || '').trim()); 226 } 227 228 function getCountryEntries() { 229 return Array.isArray(window.SoulorCountryCodes) ? window.SoulorCountryCodes : []; 230 } 231 232 function getCountryDisplayLang() { 233 return normalizeLang(document.documentElement.lang) || getDefaultLang() || 'en'; 234 } 235 236 function getDisplayNameForRegion(regionCode, lang) { 237 try { 238 return new Intl.DisplayNames([lang], { type: 'region' }).of(regionCode) || regionCode; 239 } catch { 240 return regionCode; 241 } 242 } 243 244 function toFlagEmoji(regionCode) { 245 const code = String(regionCode || '').toUpperCase(); 246 if (!/^[A-Z]{2}$/.test(code)) return ''; 247 return [...code].map(ch => String.fromCodePoint(127397 + ch.charCodeAt(0))).join(''); 248 } 249 250 function buildCountryOptionLabel(entry, lang) { 251 const flag = toFlagEmoji(entry.regionCode); 252 const localizedName = getDisplayNameForRegion(entry.regionCode, lang); 253 const name = localizedName && localizedName !== entry.regionCode ? localizedName : entry.fallbackName; 254 return `${flag ? `${flag} ` : ''}${name} (${entry.dialCode})`; 255 } 256 257 function inferCountrySelectionValue() { 258 const saved = String(localStorage.getItem(COUNTRY_CODE_KEY) || '').trim(); 259 if (saved) return saved; 260 const match = String(navigator.language || '').match(/-([A-Z]{2})$/i); 261 const region = match?.[1]?.toUpperCase() || ''; 262 const entry = getCountryEntries().find(item => item.regionCode === region); 263 return entry ? `${entry.regionCode}:${entry.dialCode}` : 'US:+1'; 264 } 265 266 function populateCountryCodeSelect() { 267 const select = el('authCountryCode'); 268 if (!select) return; 269 const entries = getCountryEntries(); 270 if (!entries.length) return; 271 const lang = getCountryDisplayLang(); 272 const previousValue = String(select.value || inferCountrySelectionValue()).trim(); 273 const popularEntries = entries.filter(entry => entry.popular); 274 const otherEntries = entries.filter(entry => !entry.popular); 275 select.innerHTML = ''; 276 const groups = [ 277 [tr('Popular Countries'), popularEntries], 278 [tr('All Countries/Regions'), otherEntries], 279 ]; 280 groups.forEach(([label, list]) => { 281 if (!list.length) return; 282 const group = document.createElement('optgroup'); 283 group.label = label; 284 list.forEach(entry => { 285 const option = document.createElement('option'); 286 option.value = `${entry.regionCode}:${entry.dialCode}`; 287 option.textContent = buildCountryOptionLabel(entry, lang); 288 group.appendChild(option); 289 }); 290 select.appendChild(group); 291 }); 292 const allValues = new Set(entries.map(entry => `${entry.regionCode}:${entry.dialCode}`)); 293 select.value = allValues.has(previousValue) ? previousValue : inferCountrySelectionValue(); 294 localStorage.setItem(COUNTRY_CODE_KEY, select.value); 295 } 296 297 function getSelectedCountryCode() { 298 const raw = String(el('authCountryCode')?.value || inferCountrySelectionValue()).trim(); 299 return raw.includes(':') ? raw.split(':')[1] : raw; 300 } 301 302 function normalizePhone(value, countryCode = getSelectedCountryCode()) { 303 const raw = String(value || '').trim(); 304 if (!raw) return ''; 305 if (raw.startsWith('+')) { 306 const intlDigits = raw.replace(/\D/g, ''); 307 return /^[1-9]\d{6,14}
307$/.test(intlDigits) ? `+${intlDigits}` : ''; 308 } 309 const ccDigits = String(countryCode || '').replace(/\D/g, ''); 310 const localDigits = raw.replace(/\D/g, '').replace(/^0+/, ''); 311 const combined = `${ccDigits}${localDigits}`; 312 return /^[1-9]\d{6,14}$/.test(combined) ? `+${combined}` : ''; 313 } 314 315 function getRegisterTarget() { 316 if (_contactMode === 'phone') { 317 return { 318 contactMode: 'phone', 319 email: '', 320 phone: normalizePhone(el('authPhone')?.value || '', getSelectedCountryCode()), 321 countryCode: getSelectedCountryCode(), 322 }; 323 } 324 return { 325 contactMode: 'email', 326 email: (el('authUser')?.value || '').trim().toLowerCase(), 327 phone: '', 328 countryCode: '', 329 }; 330 } 331 332 function getCodePrompt() { 333 return _contactMode === 'phone' 334 ? tr('Enter the code sent to your phone') 335 : tr('Enter the code sent to your email'); 336 } 337 338 function stopOtpCooldown() { 339 if (_otpTimerInterval) { 340 clearInterval(_otpTimerInterval); 341 _otpTimerInterval = null; 342 } 343 _otpCooldownSeconds = 0; 344 } 345 346 function startOtpCooldown(seconds = 60) { 347 stopOtpCooldown(); 348 _otpCooldownSeconds = seconds; 349 updateCodeSendButton(); 350 _otpTimerInterval = setInterval(() => { 351 _otpCooldownSeconds -= 1; 352 if (_otpCooldownSeconds <= 0) { 353 stopOtpCooldown(); 354 } 355 updateCodeSendButton(); 356 }, 1000); 357 } 358 359 function resetRegisterCodeState({ clearInput = false, keepNote = false } = {}) { 360 _otpEmail = ''; 361 _otpPhone = ''; 362 _otpMode = ''; 363 stopOtpCooldown(); 364 if (clearInput) { 365 const codeInput = el('authCode'); 366 if (codeInput) codeInput.value = ''; 367 } 368 if (!keepNote) { 369 const n = el('authNote'); 370 if (n) { 371 n.textContent = ''; 372 delete n.dataset.noteState; 373 } 374 } 375 updateCodeSendButton(); 376 } 377 378 function updateCodeSendButton() { 379 const btn = el('authCodeSendBtn'); 380 if (!btn) return; 381 const email = (el('authUser')?.value || '').trim().toLowerCase(); 382 const phone = normalizePhone(el('authPhone')?.value || '', getSelectedCountryCode()); 383 const canSend = (_authMode === 'register' || _authMode === 'forgot') 384 && ((_contactMode === 'phone' && !!phone) || (_contactMode === 'email' && isValidEmail(email))) 385 && !_isBusy; 386 btn.disabled = !canSend || _otpCooldownSeconds > 0; 387 if (_otpCooldownSeconds > 0) { 388 btn.textContent = `${tr('Resend Code')} (${_otpCooldownSeconds}s)`; 389 } else if ((_contactMode === 'email' && _otpEmail && email === _otpEmail) || (_contactMode === 'phone' && _otpPhone && phone === _otpPhone)) { 390 btn.textContent = tr('Resend Code'); 391 } else { 392 btn.textContent = _contactMode === 'phone' 393 ? (_deliveryMode === 'whatsapp' ? tr('Send WhatsApp Code') : tr('Send SMS Code')) 394 : tr('Send Email Code'); 395 } 396 } 397 398 function updateCodeRowVisibility() { 399 const codeRow = el('authCodeRow'); 400 if (codeRow) codeRow.style.display = (_authMode === 'register' || _authMode === 'forgot') ? 'flex' : 'none'; 401 } 402 403 window.setAuthContactMode = function (mode) { 404 // Phone auth (SMS/WhatsApp) is switched off in the UI for now; the Phone 405 // toggle stays visible but greyed, and says so when tapped. 406 if (mode === 'phone') { 407 setNote(authText('Phone auth is not available yet')); 408 mode = 'email'; 409 } 410 const contactChanged = mode !== _contactMode; 411 _contactMode = mode; 412 const emailBtn = document.getElementById('contactEmailBtn'); 413 const phoneBtn = document.getElementById('contactPhoneBtn'); 414 const emailInput = document.getElementById('authUser'); 415 const phoneRow = document.getElementById('authPhoneRow'); 416 const phoneInput = document.getElementById('authPhone'); 417 const deliveryToggle = document.getElementById('authDeliveryToggle'); 418 if (emailBtn && phoneBtn) { 419 if (mode === 'phone') { 420 phoneBtn.classList.add('active'); 421 emailBtn.classList.remove('active'); 422 emailInput.style.display = 'none'; 423 if (phoneRow) phoneRow.style.display = 'flex'; 424 phoneInput.placeholder = tr('Phone number'); 425 phoneInput.focus(); 426 phoneInput.setAttribute('autocomplete', 'tel-national'); 427 } else { 428 emailBtn.classList.add('active'); 429 phoneBtn.classList.remove('active'); 430 if (phoneRow) phoneRow.style.display = 'none'; 431 emailInput.style.display = ''; 432 emailInput.placeholder = tr('Email address'); 433 emailInput.focus(); 434 emailInput.setAttribute('autocomplete', 'email'); 435 _contactMode = 'email'; 436 } 437 } 438 if (deliveryToggle) { 439 deliveryToggle.style.display = _authMode === 'register' && _contactMode === 'phone' ? 'flex' : 'none'; 440 } 441 if (contactChanged) { 442 resetRegisterCodeState({ clearInput: true, keepNote: true }); 443 } 444 updateCodeRowVisibility(); 445 updateCodeSendButton(); 446 }; 447 448 window.setAuthDeliveryMode = function (mode) { 449 _deliveryMode = mode === 'whatsapp' ? 'whatsapp' : 'sms'; 450 const smsBtn = el('deliverySmsBtn');
451 const whatsappBtn = el('deliveryWhatsappBtn'); 452 if (smsBtn && whatsappBtn) { 453 smsBtn.classList.toggle('active', _deliveryMode === 'sms'); 454 whatsappBtn.classList.toggle('active', _deliveryMode === 'whatsapp'); 455 } 456 updateCodeSendButton(); 457 }; 458 459 window.setAuthMode = function (mode) { 460 const authModeChanged = mode !== _authMode; 461 _authMode = mode; 462 const loginBtn = document.getElementById('loginBtn'); 463 const registerBtn = document.getElementById('registerBtn'); 464 const confirmPassInput = el('authConfirmPass'); 465 const confirmPassWrap = el('authConfirmPassWrap'); 466 const forgotResetBtn = el('forgotResetBtn'); 467 const backToLoginLink = el('backToLoginLink'); 468 const forgotPasswordLink = el('forgotPasswordLink'); 469 const authDivider = el('authDivider'); 470 const contactToggle = el('authContactToggle'); 471 const pass = el('authPass'); 472 if (loginBtn && registerBtn) { 473 if (mode === 'register') { 474 registerBtn.classList.remove('auth-btn-alt'); 475 loginBtn.classList.add('auth-btn-alt'); 476 } else { 477 loginBtn.classList.remove('auth-btn-alt'); 478 registerBtn.classList.add('auth-btn-alt'); 479 } 480 loginBtn.style.display = mode === 'forgot' ? 'none' : ''; 481 registerBtn.style.display = mode === 'forgot' ? 'none' : ''; 482 } 483 if (forgotResetBtn) forgotResetBtn.style.display = mode === 'forgot' ? '' : 'none'; 484 if (backToLoginLink) backToLoginLink.style.display = mode === 'forgot' ? '' : 'none'; 485 if (forgotPasswordLink) forgotPasswordLink.style.display = mode === 'forgot' ? 'none' : ''; 486 if (authDivider) authDivider.style.display = mode === 'forgot' ? 'none' : ''; 487 const googleBtn = el('googleAuthBtn'); 488 const googleDivider = el('authGoogleDivider'); 489 if (googleBtn) googleBtn.style.display = mode === 'forgot' ? 'none' : ''; 490 if (googleDivider) googleDivider.style.display = mode === 'forgot' ? 'none' : ''; 491 if (contactToggle) contactToggle.style.display = mode === 'forgot' ? 'none' : ''; 492 if (pass) pass.placeholder = authText(mode === 'forgot' ? 'New password' : 'Password'); 493 if (confirmPassInput && confirmPassWrap) { 494 if (mode === 'register' || mode === 'forgot') { 495 confirmPassWrap.style.display = ''; 496 confirmPassInput.placeholder = authText(mode === 'forgot' ? 'Confirm new password' : 'Confirm password'); 497 } else { 498 confirmPassWrap.style.display = 'none'; 499 } 500 } 501 if (mode === 'register') { 502 window.setAuthDeliveryMode('sms'); 503 } else if (authModeChanged) { 504 resetRegisterCodeState({ clearInput: true }); 505 } 506 if (mode === 'forgot') { 507 _contactMode = 'email'; 508 const phoneRow = el('authPhoneRow'); 509 const deliveryToggle = el('authDeliveryToggle'); 510 const emailInput = el('authUser'); 511 if (phoneRow) phoneRow.style.display = 'none'; 512 if (deliveryToggle) deliveryToggle.style.display = 'none'; 513 if (emailInput) { emailInput.style.display = ''; emailInput.placeholder = tr('Email address'); } 514 updateCodeRowVisibility(); 515 } else { 516 window.setAuthContactMode(_contactMode === 'phone' ? 'phone' : 'email'); 517 } 518 updateCodeSendButton(); 519 } 520 521 // Toggle password visibility. Follows the convention every other app uses: an 522 // open eye while the password is hidden (tap to reveal), a struck-through eye 523 // while it's showing (tap to hide). 524 const EYE_ICON = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.5 12S5.5 5 12 5s10.5 7 10.5 7-4 7-10.5 7S1.5 12 1.5 12z"/><circle cx="12" cy="12" r="3.2"/></svg>'; 525 const EYE_OFF_ICON = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.6 6.2A9.9 9.9 0 0 1 12 6c6.5 0 10.5 7 10.5 7a18.8 18.8 0 0 1-3.4 4.2M6.2 7.8A18.7 18.7 0 0 0 1.5 13S5.5 20 12 20a9.9 9.9 0 0 0 4.1-.9"/><path d="M9.9 9.9a3.2 3.2 0 0 0 4.3 4.3"/><line x1="2.5" y1="2.5" x2="21.5" y2="21.5"/></svg>'; 526 527 function togglePasswordVisibility(inputId, btnId) { 528 const input = el(inputId); 529 const btn = el(btnId); 530 if (!input || !btn) return; 531 const reveal = input.type === 'password'; 532 input.type = reveal ? 'text' : 'password'; 533 btn.innerHTML = reveal ? EYE_OFF_ICON : EYE_ICON; 534 const label = authText(reveal ? 'Hide password' : 'Show password'); 535 btn.title = label; 536 btn.setAttribute('aria-label', label); 537 btn.setAttribute('aria-pressed', String(reveal)); 538 } 539 window.togglePasswordVisibility = togglePasswordVisibility; 540 541 // Submits registration. code may be '' on the first attempt â the server 542 // decides whether OTP is actually required (Supabase sends its own 543 // confirmation email for email signups, so no code is needed there; file 544 // backend and phone/Twilio still require one and respond pending:true). 545 async function submitRegister(password, confirmPassword, code) { 546 const target = getRegisterTarget(); 547 if ((!target.email && !target.phone) || !password || !confirmPassword) { 548 setError(tr('Fill in all fields')); 549 return; 550 } 551 if (password !== confirmPassword) { 552 setError(tr('Passwords do not match')); 553 return; 554 } 555 if (code && !/^\d{6,8}$/.test(code)) { 556 setError(tr('Enter the verification code')); 557 return; 558 } 559 setBusy(true); 560 try { 561 const r = await fetch('/auth/register', { 562 method: 'POST', 563 headers: { 'Content-Type': 'application/json' }, 564 body: JSON.stringify({ 565 email: target.email, 566 password, 567 phone: target.phone, 568 countryCode: target.countryCode, 569 contactMode: target.contactMode, 570 deliveryMode: _deliveryMode, 571 verificationCode: code, 572 }), 573 }); 574 const data = await r.json().catch(() => ({})); 575 if (!r.ok) { 576 setError(tr(data.error || 'Something went wrong')); 577 setBusy(false); 578 return; 579 } 580 if (data.pending && data.requiresOtp) { 581 _otpEmail = target.email; 582 _otpPhone = target.phone; 583 _otpMode = 'register'; 584 startOtpCooldown(60); 585 setNote(getCodePrompt()); 586 el('authCode')?.focus(); 587 setBusy(false); 588 return; 589 } 590 if (data.requiresLogin || data.verified) { 591 setNote(target.contactMode === 'phone' ? tr('â Phone verified! Redirecting...') : tr('â Email verified! Redirecting...')); 592 const loginR = await fetch('/auth/login', { 593 method: 'POST', 594 headers: { 'Content-Type': 'application/json' }, 595 body: JSON.stringify({ 596 email: target.email, 597 phone: target.phone, 598 countryCode: target.countryCode, 599 password, 600 contactMode: target.contactMode, 601 }) 602 }); 603 const loginData = await loginR.json().catch(() => ({})); 604 if (!loginR.ok) { 605 setError(tr(loginData.error || 'Login failed after verification')); 606 setBusy(false); 607 return; 608 } 609 sessionStorage.setItem('soulor_new_user', '1'); 610 location.reload(); 611 return; 612 } 613 sessionStorage.setItem('soulor_new_user', '1'); 614 location.reload(); 615 } catch { 616 setError(tr('Network error â is the server running?')); 617 setBusy(false); 618 } 619 } 620 621 async function sendRegisterCode(options = {}) { 622 const { fromSubmit = false } = options; 623 setError(''); 624 const target = getRegisterTarget(); 625 if (_authMode !== 'register') return false;
626 if (target.contactMode === 'email' && !isValidEmail(target.email)) { 627 setError(tr('Enter a valid email address')); 628 return false; 629 } 630 if (target.contactMode === 'phone' && !target.phone) { 631 setError(tr('Enter a valid phone number with country code')); 632 return false; 633 } 634 // Email codes come from Supabase's own signUp() (or the file backend's 635 // email OTP), both driven through /auth/register â not the Resend-based 636 // /auth/send-otp below, which only phone/Twilio still uses. 637 if (target.contactMode === 'email') { 638 const password = el('authPass')?.value || ''; 639 const confirmPassword = el('authConfirmPass')?.value || password; 640 await submitRegister(password, confirmPassword, ''); 641 return true; 642 } 643 setBusy(true); 644 try { 645 const r = await fetch('/auth/send-otp', { 646 method: 'POST', 647 headers: { 'Content-Type': 'application/json' }, 648 body: JSON.stringify({ 649 email: target.email, 650 phone: target.phone, 651 countryCode: target.countryCode, 652 contactMode: target.contactMode, 653 deliveryMode: _deliveryMode, 654 }) 655 }); 656 const data = await r.json().catch(() => ({})); 657 if (!r.ok || !data.ok) { 658 setError(tr(data.error || 'Failed to send code')); 659 setBusy(false); 660 return false; 661 } 662 _otpEmail = target.email; 663 _otpPhone = target.phone; 664 _otpMode = 'register'; 665 startOtpCooldown(60); 666 setNote(fromSubmit ? getCodePrompt() : (target.contactMode === 'phone' 667 ? (_deliveryMode === 'whatsapp' ? tr('ð² WhatsApp code sent. Check your phone.') : tr('ð± SMS code sent. Check your phone.')) 668 : tr('ð¨ New code sent! Check your inbox.'))); 669 el('authCode')?.focus(); 670 setBusy(false); 671 return true; 672 } catch { 673 setError(tr('Network error â is the server running?')); 674 setBusy(false); 675 return false; 676 } 677 } 678 679 window.sendRegisterCode = function () { 680 return sendRegisterCode(); 681 }; 682 window.resendOtpCode = window.sendRegisterCode; 683 window.verifyOtpCode = async function () { 684 const password = el('authPass')?.value || ''; 685 const confirmPassword = el('authConfirmPass')?.value || ''; 686 const code = (el('authCode')?.value || '').trim(); 687 return submitRegister(password, confirmPassword, code); 688 }; 689 window.cancelOtpVerification = function () { 690 resetRegisterCodeState({ clearInput: true }); 691 el('authCode')?.blur(); 692 el('authPass')?.focus(); 693 }; 694 695 window.authSubmit = async function (mode) { 696 setError(''); 697 const email = (el('authUser')?.value || '').trim(); 698 const phone = (el('authPhone')?.value || '').trim(); 699 const countryCode = getSelectedCountryCode(); 700 const password = el('authPass')?.value || ''; 701 702 if (mode === 'register') { 703 const confirmPassword = el('authConfirmPass')?.value || ''; 704 const code = (el('authCode')?.value || '').trim(); 705 const hasContact = _contactMode === 'phone' ? !!normalizePhone(phone, countryCode) : !!email; 706 if (!hasContact || !password || !confirmPassword) { 707 setError(tr('Fill in all fields')); 708 return; 709 } 710 if (password !== confirmPassword) { 711 setError(tr('Passwords do not match')); 712 return; 713 } 714 if (_contactMode === 'email' && !isValidEmail(email)) { 715 setError(tr('Enter a valid email address')); 716 return; 717 } 718 if (_contactMode === 'phone' && !normalizePhone(phone, countryCode)) { 719 setError(tr('Enter a valid phone number with country code')); 720 return; 721 } 722 await submitRegister(password, confirmPassword, code); 723 return; 724 } 725 726 if (!email || !password) { 727 setError(tr('Enter an email and password')); 728 return; 729 } 730 731 setBusy(true); 732 try { 733 const body = { email, password, phone, countryCode, contactMode: _contactMode }; 734 if (_contactMode === 'phone') { 735 body.email = ''; 736 } else { 737 body.phone = ''; 738 } 739 const r = await fetch('/auth/login', { 740 method: 'POST', 741 headers: { 'Content-Type': 'application/json' }, 742 body: JSON.stringify(body) 743 }); 744 const data = await r.json().catch(() => ({})); 745 if (!r.ok) { 746 setError(tr(data.error || 'Something went wrong')); 747 setBusy(false); 748 return; 749 } 750 location.reload(); 751 } catch { 752 setError(tr('Network error â is the server running?')); 753 setBusy(false); 754 } 755 }; 756 757 // Forgot-password flow, all inline in the auth card: switch to 'forgot' 758 // mode (email + code + new password fields), send a reset code to the 759 // email, verify it and set the new password in one submit, then drop 760 // back to the login form for the user to log in with it. 761 window.openForgotPassword = function () { 762 setError(''); 763 window.setAuthMode('forgot'); 764 el('authUser')?.focus(); 765 }; 766 767 async function sendForgotCode() { 768 setError(''); 769 const email = (el('authUser')?.value || '').trim().toLowerCase(); 770 if (!isValidEmail(email)) { setError(tr('Enter a valid email address')); return false; } 771 setBusy(true); 772 try { 773 const r = await fetch('/auth/forgot', { 774 method: 'POST', 775 headers: { 'Content-Type': 'application/json' }, 776 body: JSON.stringify({ email }) 777 }); 778 const data = await r.json().catch(() => ({})); 779 if (!r.ok || !data.found) { 780 setError(tr('No account found with that email. Make sure you entered the correct email address.')); 781 setBusy(false); 782 return false;
783 } 784 _otpEmail = email; 785 _otpMode = 'forgot'; 786 startOtpCooldown(60); 787 setNote(tr('ð¨ Reset code sent! Check your inbox (and spam folder), then enter it below with your new password.')); 788 el('authCode')?.focus(); 789 setBusy(false); 790 return true; 791 } catch { 792 setError(tr('Network error â is the server running?')); 793 setBusy(false); 794 return false; 795 } 796 } 797 798 window.handleAuthCodeSend = function () { 799 return _authMode === 'forgot' ? sendForgotCode() : sendRegisterCode(); 800 }; 801 802 window.submitForgotReset = async function () { 803 setError(''); 804 const email = (el('authUser')?.value || '').trim().toLowerCase(); 805 const code = (el('authCode')?.value || '').trim(); 806 const newPassword = el('authPass')?.value || ''; 807 const confirmPassword = el('authConfirmPass')?.value || ''; 808 if (!isValidEmail(email)) { setError(tr('Enter a valid email address')); return; } 809 if (!code) { setError(tr('Enter the code sent to your email')); return; } 810 if (!newPassword || !confirmPassword) { setError(tr('Fill in all fields')); return; } 811 if (newPassword !== confirmPassword) { setError(tr('Passwords do not match')); return; } 812 setBusy(true); 813 try { 814 const r = await fetch('/auth/reset-password', { 815 method: 'POST', 816 headers: { 'Content-Type': 'application/json' }, 817 body: JSON.stringify({ email, code, newPassword }) 818 }); 819 const data = await r.json().catch(() => ({})); 820 if (!r.ok) { 821 setError(tr(data.error || 'Could not reset password')); 822 setBusy(false); 823 return; 824 } 825 window.setAuthMode('login'); 826 const passInput = el('authPass'); 827 if (passInput) passInput.value = ''; 828 setNote(tr('â Password reset! Log in with your new password.')); 829 setBusy(false); 830 } catch { 831 setError(tr('Network error â is the server running?')); 832 setBusy(false); 833 } 834 }; 835 836 window.authLogout = function () { 837 window.showConfirm?.(tr('Sign out?'), async () => { 838 try { await fetch('/auth/logout', { method: 'POST' }); } catch { /* ignore */ } 839 location.reload(); 840 }); 841 }; 842 843 // ââ Onboarding wizard (4 steps: name â interests â companion â done) âââââââââ 844 845 const OB_EMOJIS = ['ð¸','ð¦','ð±','â¡','ð','ð','ð¦','ðº','ð¦','ð¥']; 846 const OB_INTERESTS = ['Music','Gaming','Fitness','Cooking','Travel','Books','Art','Movies','Fashion','Tech','Sports','Anime','Nature','Photography','Wellness','Spirituality']; 847 let _obEmoji = OB_EMOJIS[0]; 848 let _obVibe = 'bff'; 849 const _obInterests = new Set(); 850 851 function showOnboarding() { 852 const grid = el('obEmojiGrid'); 853 if (grid) { 854 grid.innerHTML = OB_EMOJIS.map((e, i) => 855 `<button class="ob-emoji-btn${i === 0 ? ' sel' : ''}" data-onclick="obEmoji" data-arg="${escapeHtml(e)}">${e}</button>` 856 ).join(''); 857 } 858 const ig = el('obInterestGrid'); 859 if (ig) { 860 ig.innerHTML = OB_INTERESTS.map(t => 861 `<button class="ob-interest-btn" data-onclick="obToggleInterest" data-arg="${escapeHtml(t)}">${t}</button>` 862 ).join(''); 863 } 864 const ov = el('onboardOverlay'); 865 if (ov) ov.style.display = 'flex'; 866 } 867 868 window.obNext = function (step) { 869 if (step === 1) { 870 const name = (el('obName')?.value || '').trim(); 871 if (!name) { el('obName')?.focus(); return; } 872 el('obStep1').classList.remove('active'); 873 el('obStep2').classList.add('active'); 874 el('obDot1').classList.remove('active'); 875 el('obDot2').classList.add('active'); 876 } else if (step === 2) { 877 el('obStep2').classList.remove('active'); 878 el('obStep3').classList.add('active'); 879 el('obDot2').classList.remove('active'); 880 el('obDot3').classList.add('active'); 881 } else if (step === 3) { 882 const compName = (el('obCompName')?.value || '').trim() || 'Luna'; 883 el('obStep3').classList.remove('active'); 884 el('obStep4').classList.add('active'); 885 el('obDoneEmoji').textContent = _obEmoji; 886 el('obDoneCompName').textContent = compName; 887 el('obDot3').classList.remove('active'); 888 el('obDot4').classList.add('active'); 889 } 890 }; 891 892 window.obToggleInterest = function (btn, topic) { 893 if (_obInterests.has(topic)) { _obInterests.delete(topic); btn.classList.remove('sel'); } 894 else { _obInterests.add(topic); btn.classList.add('sel'); } 895 }; 896 897 window.obEmoji = function (btn, emoji) { 898 _obEmoji = emoji; 899 el('obEmojiGrid')?.querySelectorAll('.ob-emoji-btn').forEach(b => b.classList.remove('sel')); 900 btn.classList.add('sel'); 901 }; 902 903 window.obVibe = function (btn, vibe) { 904 _obVibe = vibe; 905 el('obVibeRow')?.querySelectorAll('.ob-vibe-btn').forEach(b => b.classList.remove('active')); 906 btn.classList.add('active'); 907 }; 908 909 window.obFinish = function () { 910 const userName = (el('obName')?.value || '').trim(); 911 const compName = (el('obCompName')?.value || '').trim() || 'Luna'; 912 const defaultLang = getDefaultLang(); 913 if (userName) localStorage.setItem('soulor-ai_user_name', userName); 914 if (_obInterests.size) localStorage.setItem('soulor_interests', JSON.stringify([..._obInterests])
914); 915 const compId = 'ob_' + Date.now(); 916 const companion = { 917 id: compId, name: compName, avatar: _obEmoji, 918 personalities: [_obVibe], vibe: _obVibe, 919 language: defaultLang, gender: 'female', 920 created: Date.now(), lastMessage: '', lastTime: Date.now(), 921 }; 922 localStorage.setItem('soulor-ai_companions', JSON.stringify([companion])); 923 localStorage.setItem('soulor-ai_current', compId); 924 location.reload(); 925 }; 926 927 // Submit on Enter from the password field. 928 document.addEventListener('DOMContentLoaded', () => { 929 syncStaticAuthCopy(); 930 el('authPass')?.addEventListener('keydown', e => { 931 if (e.key !== 'Enter') return; 932 if (_authMode === 'forgot') window.submitForgotReset(); 933 else window.authSubmit(_authMode); 934 }); 935 el('authConfirmPass')?.addEventListener('keydown', e => { 936 if (e.key !== 'Enter') return; 937 if (_authMode === 'forgot') window.submitForgotReset(); 938 else window.authSubmit('register'); 939 }); 940 el('authCode')?.addEventListener('input', e => { 941 const nextValue = String(e.target.value || '').replace(/\D/g, '').slice(0, 8); 942 e.target.value = nextValue; 943 }); 944 el('authCode')?.addEventListener('keydown', e => { 945 if (e.key !== 'Enter') return; 946 if (_authMode === 'forgot') window.submitForgotReset(); 947 else window.authSubmit('register'); 948 }); 949 el('authUser')?.addEventListener('input', e => { 950 const nextEmail = String(e.target.value || '').trim().toLowerCase(); 951 if (_otpEmail && nextEmail !== _otpEmail) { 952 resetRegisterCodeState({ clearInput: true, keepNote: true }); 953 } 954 updateCodeSendButton(); 955 }); 956 el('authPhone')?.addEventListener('input', e => { 957 const nextPhone = normalizePhone(e.target.value || '', getSelectedCountryCode()); 958 if (_otpPhone && nextPhone !== _otpPhone) { 959 resetRegisterCodeState({ clearInput: true, keepNote: true }); 960 } 961 updateCodeSendButton(); 962 }); 963 el('authCountryCode')?.addEventListener('change', () => {
964 localStorage.setItem(COUNTRY_CODE_KEY, String(el('authCountryCode')?.value || '')); 965 const nextPhone = normalizePhone(el('authPhone')?.value || '', getSelectedCountryCode()); 966 if (_otpPhone && nextPhone !== _otpPhone) { 967 resetRegisterCodeState({ clearInput: true, keepNote: true }); 968 } 969 updateCodeSendButton(); 970 }); 971 populateCountryCodeSelect(); 972 new MutationObserver(() => populateCountryCodeSelect()) 973 .observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] }); 974 new MutationObserver(() => syncStaticAuthCopy()) 975 .observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] }); 976 // Initialize with login mode 977 window.setAuthMode('login'); 978 consumeOAuthParams(); 979 queueMicrotask(() => window.applyUILang?.().catch(() => {})); 980 check(); 981 }); 982})(); 983 984// Handlers for markup this module builds. Kept out of the markup itself so no 985// attribute ever carries executable JS; actionRuntime.js binds them, including 986// on nodes added later. Per-element values travel in data-arg. 987window.registerActions?.({ 988 "obEmoji": function () { obEmoji(this, this.dataset.arg) }, 989 "obToggleInterest": function () { obToggleInterest(this, this.dataset.arg) }, 990}, ["click"]);
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.