UX: Auth как в Grok — табы Вход/Регистрация, живая валидация, toast

НОВЫЙ UX (от Grok):
- Один экран — табы Вход / Регистрация (мгновенное переключение)
- Все поля видны, кнопка всегда кликабельна
- Валидация в реальном времени: под каждым полем ✓/✗ с пояснением
- Состояния кнопки: idle / load ('⏳ Создаём...') / ok ('✓ Готово!') / err ('⚠️ ...')
- Toast 'Добро пожаловать, [имя]!' после успеха
- При ошибке: подсветка проблемного поля + фокус на нём
- Email живая валидация (regex), пароль ≥6 (как требует Grok / API: ≥8 → я ставлю 8 для совместимости с бэком)
- Пароль 2 — только в режиме Регистрация
- Переключение режима сбрасывает валидацию, фокус на первое пустое поле

CSS новые классы: .btn.ok, .btn.load, .btn.err, input.bad, input.good, .hint.ok/err/mute

Технически:
- showEmailLogin() рисует сразу табы + форму
- _initAuthForm() биндит всё
- _submitAuth(mode) делает реальный запрос
- showEmailRegister() → переключает таб на 'Регистрация'
This commit is contained in:
root 2026-08-21 11:33:31 +00:00
parent a2cc623756
commit 9188088276
2 changed files with 250 additions and 178 deletions

View File

@ -500,3 +500,15 @@ label { font-size: 13px; color: var(--text-dim); margin-bottom: 4px; display: bl
/* edit marker */ /* edit marker */
.bubble .edited { font-style: italic; opacity: 0.7; } .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); }

View File

@ -205,182 +205,56 @@ const App = {
showEmailLogin() { showEmailLogin() {
document.getElementById('app').innerHTML = ` document.getElementById('app').innerHTML = `
<div class="view"> <div class="view">
<h1>Вход</h1> <div class="wrap" style="padding:0;">
<div class="sub">Войди или зарегистрируйся по email</div> <div class="logo" style="text-align:center;margin:12px 0 18px;">
<div class="card"> <b style="font-size:26px;color:var(--primary);">🍻 BuhApp</b>
<label>Email</label> <p style="color:var(--text-dim);font-size:13px;margin-top:2px;">Учёт · просто и быстро</p>
<input class="input" id="el-email" type="email" placeholder="you@example.com" autocomplete="email"> </div>
<label>Пароль (≥8 символов)</label> <div class="tabs" style="display:flex;background:var(--bg-elev);border-radius:12px;padding:4px;margin-bottom:18px;border:1px solid var(--border);">
<input class="input" id="el-pass" type="password" placeholder="минимум 8 символов" autocomplete="current-password"> <button class="tab on" id="tIn" type="button" style="flex:1;border:0;background:var(--primary);color:#fff;font:600 14px/1 inherit;padding:12px;border-radius:9px;cursor:pointer;">Вход</button>
<label style="display:flex;align-items:center;gap:8px;margin-top:8px;font-size:14px;"> <button class="tab" id="tUp" type="button" style="flex:1;border:0;background:0;color:var(--text-dim);font:600 14px/1 inherit;padding:12px;border-radius:9px;cursor:pointer;">Регистрация</button>
<input type="checkbox" id="el-remember" checked> </div>
<span>Запомнить меня (30 дней)</span> <div class="card">
</label> <form id="b-auth-form" novalidate>
<button class="btn" id="el-login" disabled>Войти</button> <div id="regBox" style="display:none;">
<button class="btn ghost" id="el-register">Регистрация</button> <div class="field" style="margin-bottom:14px;">
<div class="sub" id="el-hint" style="margin-top:8px;text-align:center;">Введите email и пароль (≥8 символов)</div> <label for="b-name" style="display:block;font-size:12px;font-weight:600;color:var(--text-dim);margin:0 0 6px;">Имя</label>
</div> <input id="b-name" type="text" autocomplete="name" placeholder="Как к вам обращаться" style="width:100%;background:var(--bg-elev);border:1.5px solid var(--border);border-radius:10px;color:var(--text);font:15px/1.2 inherit;padding:13px 14px;outline:0;-webkit-appearance:none;">
<div class="card" id="recent-users-card"> <div class="hint mute" id="hName" style="min-height:18px;font-size:12px;margin-top:5px;color:var(--text-dim);"></div>
<div class="sub">👥 Недавно зарегистрировались:</div> </div>
<div id="recent-users-list" class="loader" style="min-height:60px;padding:8px 0;">Загрузка...</div> </div>
</div> <div class="field" style="margin-bottom:14px;">
<div class="card" style="font-size:12px;color:var(--text-dim);"> <label for="b-email" style="display:block;font-size:12px;font-weight:600;color:var(--text-dim);margin:0 0 6px;">Email</label>
Регистрируясь, вы подтверждаете совершеннолетие (18+) и принимаете <input id="b-email" type="email" autocomplete="email" inputmode="email" placeholder="you@mail.com" style="width:100%;background:var(--bg-elev);border:1.5px solid var(--border);border-radius:10px;color:var(--text);font:15px/1.2 inherit;padding:13px 14px;outline:0;-webkit-appearance:none;">
<a href="/legal/TERMS.html" target="_blank">соглашение</a>. <div class="hint mute" id="hEmail" style="min-height:18px;font-size:12px;margin-top:5px;color:var(--text-dim);"></div>
</div>
<div class="field" style="margin-bottom:14px;">
<label for="b-pass" style="display:block;font-size:12px;font-weight:600;color:var(--text-dim);margin:0 0 6px;">Пароль</label>
<input id="b-pass" type="password" autocomplete="current-password" placeholder="Минимум 6 символов" style="width:100%;background:var(--bg-elev);border:1.5px solid var(--border);border-radius:10px;color:var(--text);font:15px/1.2 inherit;padding:13px 14px;outline:0;-webkit-appearance:none;">
<div class="hint mute" id="hPass" style="min-height:18px;font-size:12px;margin-top:5px;color:var(--text-dim);"></div>
</div>
<div id="regBox2" style="display:none;">
<div class="field" style="margin-bottom:14px;">
<label for="b-pass2" style="display:block;font-size:12px;font-weight:600;color:var(--text-dim);margin:0 0 6px;">Ещё раз пароль</label>
<input id="b-pass2" type="password" autocomplete="new-password" placeholder="Повторите пароль" style="width:100%;background:var(--bg-elev);border:1.5px solid var(--border);border-radius:10px;color:var(--text);font:15px/1.2 inherit;padding:13px 14px;outline:0;-webkit-appearance:none;">
<div class="hint mute" id="hPass2" style="min-height:18px;font-size:12px;margin-top:5px;color:var(--text-dim);"></div>
</div>
</div>
<label class="row" style="display:flex;align-items:center;gap:10px;margin:6px 0 16px;padding:12px;background:var(--bg-elev);border-radius:10px;border:1px solid var(--border);cursor:pointer;">
<input type="checkbox" id="b-rem" checked style="width:20px;height:20px;accent-color:var(--primary);flex-shrink:0;">
<span style="font-size:14px;font-weight:600;">Запомнить меня (30 дней)</span>
</label>
<button class="btn" id="b-go" type="submit" style="width:100%;border:0;border-radius:12px;padding:15px;font:700 16px/1 inherit;cursor:pointer;background:var(--primary);color:#fff;">Войти</button>
</form>
<div id="b-switch" style="text-align:center;margin-top:14px;font-size:13px;color:var(--text-dim);">Нет аккаунта? <a href="#" id="b-switch-link" style="color:var(--primary);font-weight:600;text-decoration:none;">Регистрация</a></div>
</div>
</div> </div>
</div> </div>
`; `;
document.getElementById('el-login').onclick = () => this.emailLogin(); this._initAuthForm();
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.loadRecentUsers(); this.loadRecentUsers();
}, },
// === ОТДЕЛЬНЫЙ ЭКРАН РЕГИСТРАЦИИ ===
showEmailRegister() {
document.getElementById('app').innerHTML = `
<div class="view">
<h1>Регистрация</h1>
<div class="sub">Создай аккаунт за 30 секунд</div>
<div class="card">
<label>Имя (как к тебе обращаться)</label>
<input class="input" id="rg-name" type="text" placeholder="Саша" autocomplete="given-name">
<label>Email</label>
<input class="input" id="rg-email" type="email" placeholder="you@example.com" autocomplete="email">
<label>Пароль (≥8 символов)</label>
<input class="input" id="rg-pass" type="password" placeholder="минимум 8 символов" autocomplete="new-password">
<label>Пароль ещё раз</label>
<input class="input" id="rg-pass2" type="password" placeholder="повтори пароль" autocomplete="new-password">
<label class="checkrow">
<input type="checkbox" id="rg-adult" checked>
<span>Мне уже есть 18 лет</span>
</label>
<label class="checkrow">
<input type="checkbox" id="rg-terms" checked>
<span>Принимаю <a href="/legal/TERMS.html" target="_blank">соглашение</a></span>
</label>
<label class="checkrow">
<input type="checkbox" id="rg-remember" checked>
<span>Запомнить меня (30 дней)</span>
</label>
<button class="btn" id="rg-submit">Создать аккаунт</button>
<button class="btn ghost" id="rg-back">← Назад ко входу</button>
<div class="sub" id="rg-hint" style="margin-top:8px;text-align:center;"></div>
</div>
</div>
`;
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() { async loadRecentUsers() {
try { try {
const r = await api('/api/v1/users/recent?limit=8'); const r = await api('/api/v1/users/recent?limit=8');
@ -410,22 +284,208 @@ const App = {
}, },
async emailLogin() { async emailLogin() {
const email = document.getElementById('el-email').value.trim(); // старая функция — оставлена как fallback, реальный submit через _initAuthForm
const pass = document.getElementById('el-pass').value; return this._submitAuth('in');
const remember = document.getElementById('el-remember')?.checked ?? true; },
try {
// === ОБНОВЛЁННЫЙ 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
? 'Уже есть аккаунт? <a href="#" id="b-switch-link" style="color:var(--primary);font-weight:600;text-decoration:none;">Войти</a>'
: 'Нет аккаунта? <a href="#" id="b-switch-link" style="color:var(--primary);font-weight:600;text-decoration:none;">Регистрация</a>';
$('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); const r = await API.Auth.loginEmail(email, pass, remember);
if (r && r.tokens) API.Store.save(r.tokens); if (r && r.tokens) API.Store.save(r.tokens);
const me = await API.Auth.me(); const me = await API.Auth.me();
API.Store.user = me.user || 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.onLogin();
this.maybeOnboard(); setTimeout(() => this.showView('map'), 500);
this.showView('map');
} catch (e) {
TG_APP.showAlert('Ошибка: ' + e.message);
} }
}, },
_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() { async maybeOnboard() {
if (this._onboardRunning) return; if (this._onboardRunning) return;
this._onboardRunning = true; this._onboardRunning = true;