PageSourceSearch

https://zemexx.ru/my/auth-overlay.js?v=clibanner1

js zemexx.ru collected 2026-09-29 17:56:27 UTC 18,849 bytes, 441 lines download raw bytes

1/* eslint-disable */
2/**
3 * Auth Overlay for Zemexx ЛК
4 * --------------------------
5 * Накладывает UX-исправления (P0–P3 по аудиту 2026-05-25)
6 * на страницы /my/auth/* без пересборки Vue-бандла.
7 *
8 * Покрытие:
9 *  P0  — autocomplete, label[for]/input[id], focus-visible, контраст кнопки
10 *  P1  — strength meter, контраст подзаголовка, placeholder [email protected]
11 *  P2  — Remember me, inline валидация email, live-сверка паролей,
12 *        карточка, расстояние логотип/форма, "Забыли пароль" underline
13 *  P3  — inputmode=numeric для tel, dynamic title, hints, * метка обязательности
14 *
15 * Не трогает другие overlay (office / chat-admin) и не подключается вне /my/auth/.
16 */
17(function () {
18  'use strict';
19
20  // ---- Guard: запускаемся для всего /my, но активны только на /my/auth/* ----
21  if (!location.pathname.startsWith('/my')) return;
22  function isAuthArea() { return location.pathname.startsWith('/my/auth'); }
23  function syncBodyClass() {
24    document.body.classList.toggle('auth-ov', isAuthArea());
25  }
26  syncBodyClass();
27
28  // ---- Конфиги ----------------------------------------------------------
29
30  /** Сопоставление пути → метаданные страницы для title и контекста */
31  const ROUTE_META = [
32    { test: /\/auth\/login(?:\/|$)/, title: 'Вход — Zemexx', kind: 'login' },
33    { test: /\/auth\/register(?:\/|$)/, title: 'Регистрация — Zemexx', kind: 'register' },
34    { test: /\/auth\/forgot/, title: 'Восстановление пароля — Zemexx', kind: 'forgot' },
35    { test: /\/auth\/reset/, title: 'Новый пароль — Zemexx', kind: 'reset' },
36    { test: /\/auth\/verify/, title: 'Подтверждение — Zemexx', kind: 'verify' },
37    { test: /\/auth\/otp/, title: 'Код подтверждения — Zemexx', kind: 'otp' },
38  ];
39
40  /** Эвристика: что это за input по подсказкам в label/placeholder/name/type */
41  function detectInputKind(input, labelText) {
42    const type = (input.getAttribute('type') || 'text').toLowerCase();
43    const ph = (input.getAttribute('placeholder') || '').toLowerCase();
44    const nm = (input.getAttribute('name') || '').toLowerCase();
45    const lbl = (labelText || '').toLowerCase();
46    const hay = `${ph} ${nm} ${lbl}`;
47
48    if (type === 'password') return 'password';
49    if (type === 'email' || /email|e-?mail|почт/.test(hay)) return 'email';
50    if (type === 'tel' || /телефон|phone|tel/.test(hay)) return 'tel';
51    if (/код|sms|otp|подтвержд/.test(hay) && (type === 'text' || type === 'number')) return 'otp';
52    if (/фамилия|surname|last\s*name/.test(hay)) return 'family-name';
53    if (/имя\b|first\s*name|name(?!.*last)/.test(hay)) return 'given-name';
54    if (/отчество|middle|patronymic/.test(hay)) return 'additional-name';
55    return null;
56  }
57
58  function currentRouteMeta() {
59    for (const r of ROUTE_META) {
60      if (r.test.test(location.pathname)) return r;
61    }
62    return { title: 'Личный кабинет — Zemexx', kind: 'other' };
63  }
64
65  // ---- Title + favicon --------------------------------------------------
66
67  function applyTitle() {
68    const meta = currentRouteMeta();
69    if (document.title !== meta.title) document.title = meta.title;
70  }
71
72  function ensureFavicon() {
73    if (document.querySelector('link[rel="icon"], link[rel="shortcut icon"]')) return;
74    const link = document.createElement('link');
75    link.rel = 'icon';
76    link.type = 'image/svg+xml';
77    link.href = '/my/logo.svg';
78    document.head.appendChild(link);
79  }
80
81  // ---- Уникальный ID для пар label/input --------------------------------
82
83  let _uid = 0;
84  function nextId(prefix) {
85    _uid += 1;
86    return `${prefix}-ov-${_uid}`;
87  }
88
89  // ---- Обработка одного input ------------------------------------------
90
91  function enhanceInput(input) {
92    if (input.dataset.ovDone === '1') return;
93
94    const group = input.closest('.form-group');
95    const label = group ? group.querySelector('label') : null;
96    const labelText = label ? label.textContent.trim() : '';
97    const kind = detectInputKind(input, labelText);
98    const route = currentRouteMeta();
99
100    // 1) label[for] ↔ input[id] association (P0)
101    if (label && input) {
102      if (!input.id) input.id = nextId(kind || 'field');
103      if (!label.getAttribute('for')) label.setAttribute('for', input.id);
104    }
105
106    // 2) autocomplete + inputmode (P0/P3)
107    if (kind === 'email') {
108      if (!input.getAttribute('autocomplete')) {
109        input.setAttribute('autocomplete', route.kind === 'register' ? 'email' : 'username');
110      }
111      if (!input.getAttribute('inputmode')) input.setAttribute('inputmode', 'email');
112      if (!input.getAttribute('placeholder') || /введите/i.test(input.getAttribute('placeholder') || '')) {
113        input.setAttribute('placeholder', '[email protected]');
114      }
115      input.setAttribute('spellcheck', 'false');
116      input.setAttribute('autocapitalize', 'off');
117    } else if (kind === 'tel') {
118      if (!input.getAttribute('autocomplete')) input.setAttribute('autocomplete', 'tel');
119      if (!input.getAttribute('inputmode')) input.setAttribute('inputmode', 'tel');
120    } else if (kind === 'otp') {
121      if (!input.getAttribute('autocomplete')) input.setAttribute('autocomplete', 'one-time-code');
122      if (!input.getAttribute('inputmode')) input.setAttribute('inputmode', 'numeric');
123      input.setAttribute('pattern', '[0-9]*');
124    } else if (kind === 'password') {
125      const isNew = route.kind === 'register' || route.kind === 'reset';
126      if (!input.getAttribute('autocomplete')) {
127        input.setAttribute('autocomplete', isNew ? 'new-password' : 'current-password');
128      }
129    } else if (kind === 'family-name' || kind === 'given-name' || kind === 'additional-name') {
130      if (!input.getAttribute('autocomplete')) input.setAttribute('autocomplete', kind);
131    }
132
133    // 3) required asterisk visual marker (P3)
134    if (label && input.required && !label.querySelector('.req-mark')) {
135      const star = document.createElement('span');
136      star.className = 'req-mark';
137      star.textContent = ' *';
138      star.setAttribute('aria-hidden', 'true');
139      label.appendChild(star);
140      if (!input.getAttribute('aria-required')) input.setAttribute('aria-required', 'true');
141    }
142
143    // 4) inline валидация email на blur (P2)
144    if (kind === 'email') {
145      input.addEventListener('blur', () => validateEmailField(input), { passive: true });
146      input.addEventListener('input', () => clearOvError(input), { passive: true });
147    }
148
149    // 5) password strength meter (P1) — только новые пароли
150    if (kind === 'password' && (route.kind === 'register' || route.kind === 'reset')) {
151      const isConfirm = /повтор|подтвер|confirm/i.test(labelText);
152      if (!isConfirm) attachStrengthMeter(input);
153      else attachMatchHint(input);
154    }
155
156    // 6) hint texts (P3)
157    addFieldHint(input, kind, route, group);
158
159    input.dataset.ovDone = '1';
160  }
161
162  // ---- Email validation -------------------------------------------------
163
164  const EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
165
166  function validateEmailField(input) {
167    const val = (input.value || '').trim();
168    if (!val) return clearOvError(input);
169    if (!EMAIL_RE.test(val)) {
170      showOvError(input, 'Похоже, в адресе опечатка. Пример: [email protected]');
171    } else {
172      clearOvError(input);
173    }
174  }
175
176  function showOvError(input, msg) {
177    clearOvError(input);
178    const group = input.closest('.form-group');
179    if (!group) return;
180    const div = document.createElement('div');
181    div.className = 'field-error ov-inline-error';
182    div.textContent = msg;
183    group.appendChild(div);
184    input.setAttribute('aria-invalid', 'true');
185  }
186
187  function clearOvError(input) {
188    const group = input.closest('.form-group');
189    if (!group) return;
190    group.querySelectorAll('.ov-inline-error').forEach((n) => n.remove());
191    input.removeAttribute('aria-invalid');
192  }
193
194  // ---- Password strength meter ------------------------------------------
195
196  function scorePassword(pwd) {
197    if (!pwd) return { score: 0, label: '' };
198    let s = 0;
199    if (pwd.length >= 8) s += 1;
200    if (pwd.length >= 12) s += 1;
201    if (/[a-zа-яё]/i.test(pwd) && /[A-ZА-ЯЁ]/.test(pwd)) s += 1;
202    if (/\d/.test(pwd) && /[^A-Za-zА-Яа-яЁё0-9]/.test(pwd)) s += 1;
203    if (s > 4) s = 4;
204    const labels = ['', 'Слабый', 'Так себе', 'Хороший', 'Надёжный'];
205    const colors = ['#9ca3af', '#dc2626', '#d97706', '#059669', '#047857'];
206    return { score: s, label: labels[s], color: colors[s] };
207  }
208
209  function attachStrengthMeter(input) {
210    const group = input.closest('.form-group');
211    if (!group || group.querySelector('.ov-pwd-meter')) return;
212
213    const meter = document.createElement('div');
214    meter.className = 'ov-pwd-meter';
215    meter.setAttribute('aria-live', 'polite');
216    meter.dataset.score = '0';
217    meter.innerHTML = `
218      <div class="ov-pwd-meter-bars" aria-hidden="true">
219        <div class="ov-pwd-meter-bar"></div>
220        <div class="ov-pwd-meter-bar"></div>
221        <div class="ov-pwd-meter-bar"></div>
222        <div class="ov-pwd-meter-bar"></div>
223      </div>
224      <div class="ov-pwd-meter-label">
225        <span>Надёжность пароля</span><strong>—</strong>
226      </div>`;
227    group.appendChild(meter);
228
229    const update = () => {
230      const r = scorePassword(input.value || '');
231      meter.dataset.score = String(r.score);
232      meter.style.setProperty('--ov-pwd-color', r.color);
233      const strong = meter.querySelector('strong');
234      if (strong) strong.textContent = r.label || '—';
235      // Trigger any attached match hint update
236      document.dispatchEvent(new CustomEvent('ov:pwd-changed'));
237    };
238    input.addEventListener('input', update, { passive: true });
239  }
240
241  function attachMatchHint(confirmInput) {
242    const group = confirmInput.closest('.form-group');
243    if (!group || group.querySelector('.ov-match-hint')) return;
244
245    const hint = document.createElement('div');
246    hint.className = 'ov-match-hint';
247    hint.setAttribute('aria-live', 'polite');
248    group.appendChild(hint);
249
250    const findFirstPwd = () => {
251      const all = document.querySelectorAll('.auth-page input[type="password"]');
252      for (const el of all) {
253        if (el !== confirmInput) return el;
254      }
255      return null;
256    };
257
258    const update = () => {
259      const first = findFirstPwd();
260      const a = first ? first.value : '';
261      const b = confirmInput.value || '';
262      if (!b) {
263        hint.textContent = '';
264        hint.removeAttribute('data-state');
265        return;
266      }
267      if (a && a === b) {
268        hint.textContent = '✓ Пароли совпадают';
269        hint.setAttribute('data-state', 'ok');
270      } else {
271        hint.textContent = '✗ Пароли не совпадают';
272        hint.setAttribute('data-state', 'no');
273      }
274    };
275    confirmInput.addEventListener('input', update, { passive: true });
276    document.addEventListener('ov:pwd-changed', update);
277  }
278
279  // ---- Field hint helper ------------------------------------------------
280
281  function addFieldHint(input, kind, route, group) {
282    if (!group || group.querySelector('.ov-field-hint')) return;
283    let hint = null;
284    if (kind === 'email' && route.kind === 'register') {
285      hint = 'На него придёт ссылка для подтверждения регистрации.';
286    } else if (kind === 'tel' && route.kind === 'register') {
287      hint = 'Нужен, если потребуется связаться по сделке.';
288    } else if (kind === 'password' && (route.kind === 'register' || route.kind === 'reset')) {
289      const isConfirm = /повтор|подтвер|confirm/i.test((group.querySelector('label') || {}).textContent || '');
290      if (!isConfirm) hint = 'Минимум 8 символов и хотя бы один спецсимвол (! @ # $ % &).';
291    }
292    if (!hint) return;
293    const div = document.createElement('div');
294    div.className = 'ov-field-hint';
295    div.textContent = hint;
296    group.appendChild(div);
297    const helpId = nextId('hint');
298    div.id = helpId;
299    const prev = input.getAttribute('aria-describedby');
300    input.setAttribute('aria-describedby', prev ? `${prev} ${helpId}` : helpId);
301  }
302
303  // ---- "Required" note сверху формы (P3) --------------------------------
304
305  function addRequiredNote(form) {
306    if (!form || form.dataset.ovReqNote === '1') return;
307    if (!form.querySelector('input[required]')) return;
308    const note = document.createElement('div');
309    note.className = 'ov-required-note';
310    note.innerHTML = 'Поля, отмеченные <span class="req-mark">*</span>, обязательны для заполнения.';
311    form.insertBefore(note, form.firstChild);
312    form.dataset.ovReqNote = '1';
313  }
314
315  // ---- Remember me (P2, только login) -----------------------------------
316
317  function addRememberMe(form) {
318    if (!form || form.dataset.ovRemember === '1') return;
319    const route = currentRouteMeta();
320    if (route.kind !== 'login') return;
321    const submit = form.querySelector('.btn-primary');
322    if (!submit) return;
323
324    const row = document.createElement('div');
325    row.className = 'ov-remember-row';
326    const id = nextId('remember');
327    row.innerHTML = `
328      <input type="checkbox" id="${id}" checked />
329      <label for="${id}">Запомнить меня на этом устройстве</label>`;
330    submit.parentNode.insertBefore(row, submit);
331
332    const cb = row.querySelector('input');
333    try {
334      cb.checked = localStorage.getItem('ov_remember_me') !== '0';
335    } catch (_) { /* noop */ }
336    cb.addEventListener('change', () => {
337      try { localStorage.setItem('ov_remember_me', cb.checked ? '1' : '0'); } catch (_) { /* noop */ }
338    });
339
340    form.dataset.ovRemember = '1';
341  }
342
343  // ---- Главный проход по DOM --------------------------------------------
344
345  function enhanceForm() {
346    const form = document.querySelector('.auth-page .auth-form');
347    if (!form) return;
348    form.querySelectorAll('input').forEach(enhanceInput);
349    addRequiredNote(form);
350    addRememberMe(form);
351  }
352
353  // Информационный баннер на странице ВХОДА: предупреждаем про форс смены
354  // пароля после обновления ЛК (безопасность). Только на /auth/login.
355  function ensureLoginInfoStyle() {
356    if (document.getElementById('ov-login-info-style')) return;
357    const s = document.createElement('style');
358    s.id = 'ov-login-info-style';
359    s.textContent =
360      '.ov-login-info{margin:0 0 18px;padding:14px 16px;border-radius:14px;background:rgba(0,191,63,.08);border:1px solid rgba(0,191,63,.25);color:inherit;text-align:left}' +
361      '.ov-login-info-title{font-weight:700;font-size:14px;margin-bottom:6px;color:#06912f;display:flex;gap:6px}' +
362      '.ov-login-info-text{font-size:13px;line-height:1.5;opacity:.88}' +
363      '[data-theme="dark"] .ov-login-info{background:rgba(0,191,63,.12);border-color:rgba(0,191,63,.35)}' +
364      '[data-theme="dark"] .ov-login-info-title{color:#4ade80}';
365    document.head.appendChild(s);
366  }
367
368  function addLoginInfoBanner() {
369    const existing = document.querySelector('.ov-login-info');
370    if (currentRouteMeta().kind !== 'login') { if (existing) existing.remove(); return; }
371    if (existing) return;
372    const form = document.querySelector('.auth-page .auth-form');
373    if (!form || !form.parentNode) return;
374    ensureLoginInfoStyle();
375    const banner = document.createElement('div');
376    banner.className = 'ov-login-info';
377    banner.setAttribute('role', 'note');
378    banner.innerHTML =
379      '<div class="ov-login-info-title"><span aria-hidden="true">💡</span><span>Если вы являетесь клиентом компании</span></div>' +
380      '<div class="ov-login-info-text">то при первом входе в личный кабинет: <strong>Логин</strong> — ваш email из договора. <strong>Пароль</strong> — ваш номер телефона из договора (например, 89001234567). При входе система попросит задать новый постоянный пароль — это в целях безопасности.</div>';
381    form.parentNode.insertBefore(banner, form);
382  }
383
384  // Убираем подпись «Доступы выдаются при заключении договора» под заголовком
385  // входа (по запросу). Матчим по тексту — затрагивает только эту подпись.
386  function removeAccessSubtitle() {
387    document.querySelectorAll('p.auth-subtitle').forEach(function (el) {
388      if ((el.textContent || '').trim().indexOf('Доступы выдаются при заключении договора') === 0) {
389        el.remove();
390      }
391    });
392  }
393
394  function tick() {
395    syncBodyClass();
396    if (!isAuthArea()) return; // не auth — пусть app-overlay работает
397    applyTitle();
398    enhanceForm();
399    addLoginInfoBanner();
400    removeAccessSubtitle();
401  }
402
403  // ---- Init -------------------------------------------------------------
404
405  ensureFavicon();
406
407  // Перехватываем SPA-навигацию
408  const pushState = history.pushState;
409  history.pushState = function () {
410    pushState.apply(this, arguments);
411    queueMicrotask(tick);
412  };
413  const replaceState = history.replaceState;
414  history.replaceState = function () {
415    replaceState.apply(this, arguments);
416    queueMicrotask(tick);
417  };
418  window.addEventListener('popstate', tick);
419
420  // Mutation observer ловит каждое перерисовывание Vue (rAF-debounce)
421  let _tickPending = false;
422  function scheduleTick() {
423    if (_tickPending) return;
424    _tickPending = true;
425    requestAnimationFrame(() => {
426      _tickPending = false;
427      try { tick(); } catch (_) {}
428    });
429  }
430  const obs = new MutationObserver(scheduleTick);
431  function start() {
432    const app = document.getElementById('app') || document.body;
433    obs.observe(app, { childList: true, subtree: true });
434    tick();
435  }
436  if (document.readyState === 'loading') {
437    document.addEventListener('DOMContentLoaded', start, { once: true });
438  } else {
439    start();
440  }
441})();

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.