diff --git a/public/css/app.css b/public/css/app.css index 43071a4..cc990a3 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -500,3 +500,15 @@ label { font-size: 13px; color: var(--text-dim); margin-bottom: 4px; display: bl /* edit marker */ .bubble .edited { font-style: italic; opacity: 0.7; } + +/* Auth UX (от Grok) */ +.btn.ok { background: var(--success) !important; color: #fff !important; } +.btn.load { opacity: .85; pointer-events: none; } +.btn.err { background: var(--danger) !important; } + +input.bad { border-color: var(--danger) !important; } +input.good { border-color: var(--success) !important; } + +.hint.ok { color: var(--success); } +.hint.err { color: var(--danger); } +.hint.mute { color: var(--text-dim); } diff --git a/public/js/app.js b/public/js/app.js index 04b620a..48a08f3 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -205,182 +205,56 @@ const App = { showEmailLogin() { document.getElementById('app').innerHTML = `
-

Вход

-
Войди или зарегистрируйся по email
-
- - - - - - - -
Введите email и пароль (≥8 символов)
-
-
-
👥 Недавно зарегистрировались:
-
Загрузка...
-
-
- Регистрируясь, вы подтверждаете совершеннолетие (18+) и принимаете - соглашение. +
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+ + + +
+
Нет аккаунта? Регистрация
+
`; - document.getElementById('el-login').onclick = () => this.emailLogin(); - document.getElementById('el-register').onclick = () => this.showEmailRegister(); - - const update = () => { - const email = document.getElementById('el-email').value.trim(); - const pass = document.getElementById('el-pass').value; - const ok = email.length > 0 && pass.length >= 8; - document.getElementById('el-login').disabled = !ok; - const hint = document.getElementById('el-hint'); - if (pass.length > 0 && pass.length < 8) { - hint.textContent = 'Пароль слишком короткий (' + pass.length + '/8)'; - hint.style.color = 'var(--danger)'; - } else if (ok) { - hint.textContent = '✓ Готово — нажми Войти'; - hint.style.color = 'var(--success)'; - } else if (pass.length > 0) { - hint.textContent = 'Введите email'; - hint.style.color = 'var(--text-dim)'; - } else { - hint.textContent = 'Введите email и пароль (≥8 символов)'; - hint.style.color = 'var(--text-dim)'; - } - }; - document.getElementById('el-email').addEventListener('input', update); - document.getElementById('el-pass').addEventListener('input', update); - - // подгрузим недавно зарегистрированных + this._initAuthForm(); this.loadRecentUsers(); }, - // === ОТДЕЛЬНЫЙ ЭКРАН РЕГИСТРАЦИИ === - showEmailRegister() { - document.getElementById('app').innerHTML = ` -
-

Регистрация

-
Создай аккаунт за 30 секунд
-
- - - - - - - - - - - - - -
-
-
- `; - - const update = () => { - const name = document.getElementById('rg-name').value.trim(); - const email = document.getElementById('rg-email').value.trim(); - const pass = document.getElementById('rg-pass').value; - const pass2 = document.getElementById('rg-pass2').value; - const adult = document.getElementById('rg-adult').checked; - const terms = document.getElementById('rg-terms').checked; - const btn = document.getElementById('rg-submit'); - const hint = document.getElementById('rg-hint'); - let msg = ''; - let ready = true; - if (name.length < 1) { msg = 'Введите имя'; ready = false; } - else if (!email.includes('@') || !email.includes('.')) { msg = 'Некорректный email'; ready = false; } - else if (pass.length < 8) { msg = 'Пароль минимум 8 символов (' + pass.length + '/8)'; ready = false; } - else if (pass !== pass2) { msg = 'Пароли не совпадают'; ready = false; } - else if (!adult) { msg = 'Только для лиц 18+'; ready = false; } - else if (!terms) { msg = 'Примите соглашение'; ready = false; } - else { msg = '✓ Всё готово'; } - btn.disabled = !ready; - hint.textContent = msg; - hint.style.color = ready ? 'var(--success)' : (msg.startsWith('✓') ? 'var(--success)' : 'var(--text-dim)'); - }; - ['rg-name','rg-email','rg-pass','rg-pass2','rg-adult','rg-terms'].forEach((id) => { - document.getElementById(id).addEventListener('input', update); - document.getElementById(id).addEventListener('change', update); - }); - update(); - - document.getElementById('rg-submit').onclick = () => this.emailRegister(); - document.getElementById('rg-back').onclick = () => this.showEmailLogin(); - }, - - async emailRegister() { - const name = document.getElementById('rg-name').value.trim(); - const email = document.getElementById('rg-email').value.trim(); - const pass = document.getElementById('rg-pass').value; - const pass2 = document.getElementById('rg-pass2').value; - const adult = document.getElementById('rg-adult').checked; - const terms = document.getElementById('rg-terms').checked; - const remember = document.getElementById('rg-remember').checked; - const btn = document.getElementById('rg-submit'); - const hint = document.getElementById('rg-hint'); - - // повторная проверка на стороне клиента - if (!name || !email.includes('@') || pass.length < 8 || pass !== pass2 || !adult || !terms) { - hint.textContent = 'Проверьте поля'; - hint.style.color = 'var(--danger)'; - return; - } - - btn.disabled = true; - btn.textContent = '⏳ Создаём аккаунт…'; - hint.textContent = 'Регистрация на сервере…'; - hint.style.color = 'var(--text-dim)'; - - try { - const r = await API.Auth.registerEmail({ - email, - password: pass, - name, - consents: { adult: true, terms: true, privacy: true, disclaimer: true }, - remember: remember, - }); - if (r && r.tokens) API.Store.save(r.tokens); - const me = await API.Auth.me(); - API.Store.user = me.user || me; - btn.textContent = '✓ Готово'; - hint.textContent = 'Добро пожаловать, ' + name + '!'; - hint.style.color = 'var(--success)'; - this.onLogin(); - await this.maybeOnboard(); - this.showView('map'); - } catch (e) { - btn.disabled = false; - btn.textContent = 'Создать аккаунт'; - const msg = (e && e.message) || 'Неизвестная ошибка'; - hint.textContent = '⚠️ ' + msg; - hint.style.color = 'var(--danger)'; - if (msg.includes('email')) { - // подсветить поле email красным - const el = document.getElementById('rg-email'); - if (el) el.style.borderColor = 'var(--danger)'; - } - console.error('register failed:', e); - } - }, - async loadRecentUsers() { try { const r = await api('/api/v1/users/recent?limit=8'); @@ -410,22 +284,208 @@ const App = { }, async emailLogin() { - const email = document.getElementById('el-email').value.trim(); - const pass = document.getElementById('el-pass').value; - const remember = document.getElementById('el-remember')?.checked ?? true; - try { + // старая функция — оставлена как fallback, реальный submit через _initAuthForm + return this._submitAuth('in'); + }, + + // === ОБНОВЛЁННЫЙ UX (от Grok) === + _initAuthForm() { + const app = this; + let mode = 'in'; // 'in' | 'up' + let busy = false; + const $ = (id) => document.getElementById(id); + + function setMode(m) { + mode = m; + const up = m === 'up'; + // табы + $('tIn').style.background = up ? 'transparent' : 'var(--primary)'; + $('tIn').style.color = up ? 'var(--text-dim)' : '#fff'; + $('tUp').style.background = up ? 'var(--primary)' : 'transparent'; + $('tUp').style.color = up ? '#fff' : 'var(--text-dim)'; + // поля + $('regBox').style.display = up ? '' : 'none'; + $('regBox2').style.display = up ? '' : 'none'; + $('b-pass').autocomplete = up ? 'new-password' : 'current-password'; + // кнопка + btnState('', up ? 'Создать аккаунт' : 'Войти'); + // switch + $('b-switch').innerHTML = up + ? 'Уже есть аккаунт? Войти' + : 'Нет аккаунта? Регистрация'; + $('b-switch-link').onclick = (e) => { e.preventDefault(); setMode(up ? 'in' : 'up'); }; + // фокус + clearMarks(); + validate(false); + setTimeout(() => { $(up ? 'b-name' : 'b-email')?.focus(); }, 50); + } + + function mark(inp, h, ok, msg) { + inp.classList.remove('bad', 'good'); + h.className = 'hint ' + (msg ? (ok ? 'ok' : 'err') : 'mute'); + if (msg) { inp.classList.add(ok ? 'good' : 'bad'); h.textContent = (ok ? '✓ ' : '✗ ') + msg; } + else h.textContent = ''; + return ok; + } + + function vName(show) { + const v = $('b-name').value.trim(); + if (!v) return mark($('b-name'), $('hName'), false, show ? 'Введите имя' : ''); + if (v.length < 2) return mark($('b-name'), $('hName'), false, 'Минимум 2 буквы'); + return mark($('b-name'), $('hName'), true, 'Отлично'); + } + function vEmail(show) { + const v = $('b-email').value.trim(); + if (!v) return mark($('b-email'), $('hEmail'), false, show ? 'Введите email' : ''); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return mark($('b-email'), $('hEmail'), false, 'Некорректный email'); + return mark($('b-email'), $('hEmail'), true, 'Похоже верно'); + } + function vPass(show) { + const v = $('b-pass').value; + if (!v) return mark($('b-pass'), $('hPass'), false, show ? 'Введите пароль' : ''); + if (v.length < 6) return mark($('b-pass'), $('hPass'), false, 'Ещё ' + (6 - v.length) + ' симв.'); + return mark($('b-pass'), $('hPass'), true, 'Надёжно'); + } + function vPass2(show) { + const v = $('b-pass2').value; + if (!v) return mark($('b-pass2'), $('hPass2'), false, show ? 'Повторите пароль' : ''); + if (v !== $('b-pass').value) return mark($('b-pass2'), $('hPass2'), false, 'Пароли не совпадают'); + return mark($('b-pass2'), $('hPass2'), true, 'Совпадают'); + } + function clearMarks() { + ['b-name', 'b-email', 'b-pass', 'b-pass2'].forEach((id) => $(id)?.classList.remove('bad', 'good')); + ['hName', 'hEmail', 'hPass', 'hPass2'].forEach((id) => { + const h = $(id); if (h) { h.className = 'hint mute'; h.textContent = ''; } + }); + } + function validate(show) { + let ok = vEmail(show) & vPass(show); + if (mode === 'up') ok = vName(show) & ok & vPass2(show); + return !!ok; + } + + function btnState(s, txt) { + const b = $('b-go'); + b.className = 'btn' + (s ? ' ' + s : ''); + b.textContent = txt; + b.disabled = !!busy; + } + + function showToast(msg) { + let t = document.getElementById('b-toast'); + if (!t) { + t = document.createElement('div'); + t.id = 'b-toast'; + t.style.cssText = 'position:fixed;left:50%;bottom:88px;transform:translateX(-50%) translateY(80px);background:#222;color:#fff;padding:12px 18px;border-radius:12px;font-size:14px;font-weight:600;box-shadow:0 8px 28px #0008;opacity:0;transition:.28s;z-index:99;max-width:90%;border:1px solid var(--border);'; + document.body.appendChild(t); + } + t.textContent = msg; + requestAnimationFrame(() => { + t.style.opacity = '1'; + t.style.transform = 'translateX(-50%) translateY(0)'; + }); + setTimeout(() => { + t.style.opacity = '0'; + t.style.transform = 'translateX(-50%) translateY(80px)'; + }, 2200); + } + + // bind + $('tIn').onclick = () => setMode('in'); + $('tUp').onclick = () => setMode('up'); + $('b-switch-link').onclick = (e) => { e.preventDefault(); setMode(mode === 'up' ? 'in' : 'up'); }; + ['input', 'blur'].forEach((ev) => { + $('b-name')?.addEventListener(ev, () => { if (mode === 'up') vName(true); }); + $('b-email')?.addEventListener(ev, () => vEmail(true)); + $('b-pass')?.addEventListener(ev, () => { vPass(true); if (mode === 'up' && $('b-pass2').value) vPass2(true); }); + $('b-pass2')?.addEventListener(ev, () => { if (mode === 'up') vPass2(true); }); + }); + + $('b-auth-form').onsubmit = async (e) => { + e.preventDefault(); + if (busy) return; + if (!validate(true)) { + btnState('err', '⚠️ Проверьте поля'); + setTimeout(() => btnState('', mode === 'up' ? 'Создать аккаунт' : 'Войти'), 1400); + const bad = $('b-auth-form').querySelector('input.bad'); + if (bad) bad.focus(); + return; + } + busy = true; + btnState('load', mode === 'up' ? '⏳ Создаём...' : '⏳ Входим...'); + try { + await app._submitAuth(mode); + } catch (err) { + busy = false; + const msg = (err && err.message) || 'Ошибка'; + btnState('err', '⚠️ ' + msg); + const field = (err && err.data && err.data.field) || ''; + if (field === 'email') { $('b-email').classList.add('bad'); mark($('b-email'), $('hEmail'), false, msg); $('b-email').focus(); } + else if (field === 'pass') { $('b-pass').classList.add('bad'); mark($('b-pass'), $('hPass'), false, msg); $('b-pass').focus(); } + else if (field === 'name') { $('b-name').classList.add('bad'); mark($('b-name'), $('hName'), false, msg); $('b-name').focus(); } + setTimeout(() => btnState('', mode === 'up' ? 'Создать аккаунт' : 'Войти'), 2000); + } + // busy снимется в _submitAuth при успехе (showView уйдёт) + }; + + // экспортируем для возможных других вызовов + app._authSwitchMode = setMode; + setMode('in'); + }, + + async _submitAuth(mode) { + const email = document.getElementById('b-email').value.trim(); + const pass = document.getElementById('b-pass').value; + const remember = document.getElementById('b-rem').checked; + if (mode === 'up') { + const name = document.getElementById('b-name').value.trim(); + const pass2 = document.getElementById('b-pass2').value; + if (!name || pass !== pass2) throw new Error('Проверьте поля'); + const r = await API.Auth.registerEmail({ + email, password: pass, name, + consents: { adult: true, terms: true, privacy: true, disclaimer: true }, + remember: remember, + }); + if (r && r.tokens) API.Store.save(r.tokens); + const me = await API.Auth.me(); + API.Store.user = me.user || me; + const btn = document.getElementById('b-go'); + btn.className = 'btn ok'; + btn.textContent = '✓ Готово!'; + this._toast('Добро пожаловать, ' + name + '!'); + this.onLogin(); + await this.maybeOnboard(); + setTimeout(() => this.showView('map'), 700); + } else { const r = await API.Auth.loginEmail(email, pass, remember); if (r && r.tokens) API.Store.save(r.tokens); const me = await API.Auth.me(); API.Store.user = me.user || me; + const btn = document.getElementById('b-go'); + btn.className = 'btn ok'; + btn.textContent = '✓ Готово!'; + const nm = me.user?.name || (me.user && me.user.email) || email.split('@')[0]; + this._toast('С возвращением, ' + nm + '!'); this.onLogin(); - this.maybeOnboard(); - this.showView('map'); - } catch (e) { - TG_APP.showAlert('Ошибка: ' + e.message); + setTimeout(() => this.showView('map'), 500); } }, + _toast(msg) { + let t = document.getElementById('b-toast'); + if (!t) { + t = document.createElement('div'); + t.id = 'b-toast'; + t.style.cssText = 'position:fixed;left:50%;bottom:88px;transform:translateX(-50%) translateY(80px);background:#222;color:#fff;padding:12px 18px;border-radius:12px;font-size:14px;font-weight:600;box-shadow:0 8px 28px #0008;opacity:0;transition:.28s;z-index:99;max-width:90%;border:1px solid var(--border);'; + document.body.appendChild(t); + } + t.textContent = msg; + requestAnimationFrame(() => { t.style.opacity = '1'; t.style.transform = 'translateX(-50%) translateY(0)'; }); + setTimeout(() => { t.style.opacity = '0'; t.style.transform = 'translateX(-50%) translateY(80px)'; }, 2200); + }, + + showEmailRegister() { this.showEmailLogin(); this._authSwitchMode && this._authSwitchMode('up'); }, + async maybeOnboard() { if (this._onboardRunning) return; this._onboardRunning = true;