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+) и принимаете
-
соглашение.
+
+
+
🍻 BuhApp
+
Учёт · просто и быстро
+
+
+
+
+
+
`;
- 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;