PageSourceSearch

https://soulor-ai.vercel.app/js/auth.js?v=11.2

js soulor-ai.vercel.app collected 2026-10-03 07:46:20 UTC 39,220 bytes, 990 lines download raw bytes

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.