work-license-server 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/landing.js CHANGED
@@ -15,11 +15,15 @@
15
15
  let currentUser = null;
16
16
  let currentStage = 'product';
17
17
  let selectedCode = null;
18
+ let pendingAuthAction = null;
18
19
 
19
20
  async function api(path, body, method) {
21
+ const headers = {};
22
+ if (body) headers['Content-Type'] = 'application/json';
23
+ if (currentUser?.sessionKey) headers['Authorization'] = 'Bearer ' + currentUser.sessionKey;
20
24
  const res = await fetch(path, {
21
25
  method: method || (body ? 'POST' : 'GET'),
22
- headers: body ? { 'Content-Type': 'application/json' } : undefined,
26
+ headers: Object.keys(headers).length ? headers : undefined,
23
27
  body: body ? JSON.stringify(body) : undefined,
24
28
  });
25
29
  let data = {};
@@ -29,6 +33,10 @@
29
33
  return data;
30
34
  }
31
35
 
36
+ function isUserReadyForOrders() {
37
+ return Boolean(currentUser && currentUser.ofertaAccepted);
38
+ }
39
+
32
40
  /* ================= Тема ================= */
33
41
  function applyTheme(theme) {
34
42
  document.documentElement.setAttribute('data-theme', theme);
@@ -53,22 +61,20 @@
53
61
  ind.style.width = active.offsetWidth + 'px';
54
62
  }
55
63
  function switchStage(id, { scroll = true } = {}) {
56
- if (id === 'addons' && !currentUser) { openAuth(); return; }
64
+ if (id === 'addons' && !isUserReadyForOrders()) {
65
+ requireAuthAndOferta(() => switchStage('addons'));
66
+ return;
67
+ }
57
68
  if (!$('#stage-' + id)) id = 'product';
58
69
  currentStage = id;
59
70
  document.body.setAttribute('data-stage', id);
60
71
  $$('.tab').forEach(t => t.classList.toggle('active', t.dataset.stage === id));
61
72
  $$('.stage').forEach(s => s.classList.toggle('active', s.id === 'stage-' + id));
62
- const admBtn = $('#btnAdminTop');
63
- if (admBtn) {
64
- admBtn.classList.toggle('btn-primary', id === 'admin');
65
- admBtn.classList.toggle('btn-ghost', id !== 'admin');
66
- }
67
73
  moveIndicator();
68
74
  const active = $('.tab.active');
69
75
  if (active) active.scrollIntoView({ block: 'nearest', inline: 'center', behavior: reduceMotion ? 'auto' : 'smooth' });
70
76
  if (history.replaceState) {
71
- const url = id === 'product' ? '/' : (id === 'admin' && location.pathname === '/admin' ? '/admin' : '/#' + id);
77
+ const url = id === 'product' ? '/' : '/#' + id;
72
78
  history.replaceState(null, '', url);
73
79
  }
74
80
  if (scroll) window.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' });
@@ -76,7 +82,6 @@
76
82
  observeReveals();
77
83
  if (id === 'agents') playConsultantMock();
78
84
  if (id === 'franchise') requestAnimationFrame(() => MAP.ready && applyView());
79
- if (id === 'admin') refreshRegistry();
80
85
  }
81
86
  window.switchStage = switchStage;
82
87
 
@@ -124,46 +129,199 @@
124
129
  card.style.setProperty('--my', (e.clientY - r.top) + 'px');
125
130
  }, { passive: true });
126
131
 
127
- /* ================= Авторизация ================= */
128
- function openAuth() { $('#authModal').classList.add('open'); setTimeout(() => $('#authEmail').focus(), 50); }
129
- function closeAuth() { $('#authModal').classList.remove('open'); }
132
+ /* ================= Авторизация и подписание публичной оферты (ПЭП) ================= */
133
+ function openAuth(hintMsg = '') {
134
+ if ($('#authErrorMsg')) $('#authErrorMsg').textContent = hintMsg || '';
135
+ if ($('#btnLogoutModal')) $('#btnLogoutModal').style.display = currentUser ? '' : 'none';
136
+ $('#authModal').classList.add('open');
137
+ setTimeout(() => $('#authEmail')?.focus(), 50);
138
+ }
139
+ function closeAuth() {
140
+ $('#authModal').classList.remove('open');
141
+ if ($('#authErrorMsg')) $('#authErrorMsg').textContent = '';
142
+ }
143
+ function openOfertaModal() {
144
+ $('#ofertaModal')?.classList.add('open');
145
+ }
146
+ function closeOfertaModal() {
147
+ $('#ofertaModal')?.classList.remove('open');
148
+ }
149
+ function requireAuthAndOferta(actionFn, prefill = {}) {
150
+ if (isUserReadyForOrders()) {
151
+ if (typeof actionFn === 'function') actionFn();
152
+ return true;
153
+ }
154
+ pendingAuthAction = typeof actionFn === 'function' ? actionFn : null;
155
+ if (prefill.companyName && $('#authCompany')) $('#authCompany').value = prefill.companyName;
156
+ if (prefill.inn && $('#authInn')) $('#authInn').value = prefill.inn;
157
+ if (prefill.email && $('#authEmail')) $('#authEmail').value = prefill.email;
158
+ openAuth('Для оформления заказа, оплаты или резервирования франшизы зарегистрируйтесь и подпишите договор-оферту (ПЭП).');
159
+ return false;
160
+ }
161
+
130
162
  async function performLogin() {
163
+ const errBox = $('#authErrorMsg');
164
+ if (errBox) errBox.textContent = '';
165
+ const email = $('#authEmail')?.value.trim() || '';
166
+ const companyName = $('#authCompany')?.value.trim() || '';
167
+ const inn = $('#authInn')?.value.trim() || '';
168
+ const signer = $('#authSigner')?.value.trim() || '';
169
+ const licenseId = $('#authLicId')?.value.trim() || '';
170
+ const ofertaChecked = Boolean($('#authOfertaCheck')?.checked);
171
+
172
+ if (!email || !email.includes('@')) {
173
+ if (errBox) errBox.textContent = 'Укажите корректный корпоративный email.';
174
+ return;
175
+ }
176
+ if (!companyName || !inn) {
177
+ if (errBox) errBox.textContent = 'Укажите наименование организации и ИНН.';
178
+ return;
179
+ }
180
+ if (!ofertaChecked) {
181
+ if (errBox) errBox.textContent = 'Для продолжения необходимо ознакомиться с условиями Публичной оферты и поставить отметку о подписании ПЭП.';
182
+ return;
183
+ }
184
+
131
185
  try {
132
- const data = await api('/api/auth/login', {
133
- email: $('#authEmail').value.trim(),
134
- companyName: $('#authCompany').value.trim(),
135
- licenseId: $('#authLicId').value.trim(),
186
+ const data = await api('/api/auth/register', {
187
+ email,
188
+ companyName,
189
+ inn,
190
+ name: signer || companyName,
191
+ licenseId,
192
+ ofertaAccepted: true,
136
193
  });
137
194
  currentUser = data;
138
195
  try { localStorage.setItem('odant_work_session', data.sessionKey); } catch { /* приватный режим */ }
139
196
  onAuthenticated();
140
197
  closeAuth();
141
- switchStage('addons');
198
+ const nextAction = pendingAuthAction;
199
+ pendingAuthAction = null;
200
+ if (typeof nextAction === 'function') {
201
+ nextAction();
202
+ }
203
+ else {
204
+ switchStage('addons');
205
+ }
142
206
  }
143
207
  catch (err) {
144
- alert(err.message);
208
+ if (errBox) errBox.textContent = err.message;
145
209
  }
146
210
  }
211
+
212
+ function performLogout() {
213
+ currentUser = null;
214
+ pendingAuthAction = null;
215
+ try { localStorage.removeItem('odant_work_session'); } catch { /* ignore */ }
216
+ $('#tileAddons').style.display = 'none';
217
+ $('#btnAuthText').textContent = 'Войти / Регистрация';
218
+ if ($('#btnLogoutModal')) $('#btnLogoutModal').style.display = 'none';
219
+ updateOfertaBanners();
220
+ updateClientSupportCard();
221
+ closeAuth();
222
+ if (currentStage === 'addons') switchStage('product');
223
+ moveIndicator();
224
+ }
225
+
147
226
  async function restoreSession() {
148
227
  let key = '';
149
228
  try { key = localStorage.getItem('odant_work_session') || ''; } catch { /* приватный режим */ }
150
- if (!key) return;
229
+ if (!key) {
230
+ updateOfertaBanners();
231
+ updateClientSupportCard();
232
+ return;
233
+ }
151
234
  try {
152
235
  const data = await api('/api/auth/me?sessionKey=' + encodeURIComponent(key));
153
- if (data.authenticated) {
236
+ if (data.authenticated && data.user) {
154
237
  currentUser = { ...data.user, sessionKey: key };
155
238
  onAuthenticated();
156
- if (location.hash === '#addons') switchStage('addons', { scroll: false });
239
+ if (location.hash === '#addons' && isUserReadyForOrders()) switchStage('addons', { scroll: false });
157
240
  }
158
241
  }
159
242
  catch { /* сессия устарела */ }
243
+ updateOfertaBanners();
244
+ updateClientSupportCard();
160
245
  }
246
+
247
+ function updateOfertaBanners() {
248
+ const ready = isUserReadyForOrders();
249
+ $$('[data-oferta-banner]').forEach(box => {
250
+ box.classList.toggle('signed', ready);
251
+ if (ready) {
252
+ box.innerHTML = `
253
+ <div>
254
+ <span class="tag ok">✓ Договор-оферта подписан ПЭП</span>
255
+ <div style="margin-top:6px;font-size:13.5px">
256
+ Организация: <b>${esc(currentUser.companyName)}</b> (ИНН ${esc(currentUser.inn || '—')}) · Подписант: <b>${esc(currentUser.ofertaSignerName || currentUser.name || '—')}</b> · Штамп ПЭП: <code>${esc(currentUser.ofertaSignatureId || 'PEP')}</code>
257
+ </div>
258
+ </div>
259
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
260
+ <button class="btn btn-ghost btn-sm" data-action="open-oferta">Текст оферты</button>
261
+ <button class="btn btn-ghost btn-sm" data-action="auth">Профиль</button>
262
+ </div>`;
263
+ }
264
+ else {
265
+ box.innerHTML = `
266
+ <div>
267
+ <span class="tag">Требуется регистрация и акцепт оферты</span>
268
+ <div style="margin-top:6px;font-size:13.5px;color:var(--text-2)">
269
+ Любые действия по заказу комплекса, докупке лицензий, оплате через ЮKassa и резервированию региональной франшизы выполняются после регистрации и подписания Публичного договора-оферты (ПЭП).
270
+ </div>
271
+ </div>
272
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
273
+ <button class="btn btn-ghost btn-sm" data-action="open-oferta">Читать оферту</button>
274
+ <button class="btn btn-primary btn-sm" data-action="auth">Войти и подписать оферту</button>
275
+ </div>`;
276
+ }
277
+ });
278
+ }
279
+
280
+ function updateClientSupportCard() {
281
+ const box = $('#clientLicenseBox');
282
+ if (!box) return;
283
+ if (!currentUser || !currentUser.license) {
284
+ box.textContent = 'Войдите в личный кабинет и подпишите договор-оферту, чтобы получить код привязки вашего комплекса к ботам MAX, Telegram и VK.';
285
+ return;
286
+ }
287
+ const lic = currentUser.license;
288
+ const bindCode = lic.support?.bindCode || lic.licenseId;
289
+ box.textContent = [
290
+ `Организация: ${currentUser.companyName} (ИНН ${currentUser.inn || '—'})`,
291
+ `Оферта ПЭП: ${currentUser.ofertaAccepted ? ('подписана · ' + (currentUser.ofertaSignatureId || '')) : 'ожидает подписания'}`,
292
+ `Лицензия ПАК: ${lic.licenseId}`,
293
+ `Код привязки мессенджер-бота: ${bindCode}`,
294
+ `Команда для чата MAX / Telegram / VK: /bind ${bindCode}`,
295
+ `Рабочие места: ${lic.totalSeats} (${lic.baseSeats} вечных + ${lic.addonSeats} годовых)`,
296
+ `Уровень поддержки SLA: ${lic.support?.tierName || 'Enterprise'} (${lic.support?.reactionLabel || 'до 15 мин'})`,
297
+ `Остаток обращений: ${lic.support?.ticketsRemaining ?? 0} из ${lic.support?.ticketsQuota ?? 0}`,
298
+ ].join('\n');
299
+ }
300
+
161
301
  function onAuthenticated() {
162
302
  if (!currentUser) return;
163
- $('#tileAddons').style.display = '';
303
+ $('#tileAddons').style.display = isUserReadyForOrders() ? '' : 'none';
164
304
  $('#btnAuthText').textContent = currentUser.companyName || currentUser.email;
165
- $('#inpLicId').value = currentUser.licenseId || '';
166
- $('#userBadgeBox').innerHTML = `${esc(currentUser.companyName)} · лицензия <b>${esc(currentUser.licenseId)}</b>`;
305
+ if ($('#inpLicId')) $('#inpLicId').value = currentUser.licenseId || '';
306
+ if ($('#inpCabinetHubToken')) $('#inpCabinetHubToken').value = currentUser.hubToken || '';
307
+ if ($('#authEmail')) $('#authEmail').value = currentUser.email || '';
308
+ if ($('#authCompany')) $('#authCompany').value = currentUser.companyName || '';
309
+ if ($('#authInn') && currentUser.inn) $('#authInn').value = currentUser.inn;
310
+ if ($('#authSigner') && (currentUser.ofertaSignerName || currentUser.name)) $('#authSigner').value = currentUser.ofertaSignerName || currentUser.name;
311
+ if ($('#authLicId')) $('#authLicId').value = currentUser.licenseId || '';
312
+ if ($('#authOfertaCheck')) $('#authOfertaCheck').checked = Boolean(currentUser.ofertaAccepted);
313
+ if ($('#inpPakOrg') && currentUser.companyName) $('#inpPakOrg').value = currentUser.companyName;
314
+ if ($('#inpPakInn') && currentUser.inn) $('#inpPakInn').value = currentUser.inn;
315
+ if ($('#inpPakEmail') && currentUser.email) $('#inpPakEmail').value = currentUser.email;
316
+ if ($('#inpPartnerCompany') && currentUser.companyName) $('#inpPartnerCompany').value = currentUser.companyName;
317
+ if ($('#inpPartnerInn') && currentUser.inn) $('#inpPartnerInn').value = currentUser.inn;
318
+ if ($('#inpPartnerEmail') && currentUser.email) $('#inpPartnerEmail').value = currentUser.email;
319
+ if ($('#botText') && currentUser.license?.support?.bindCode && $('#botText').value === '/start') {
320
+ $('#botText').value = '/bind ' + currentUser.license.support.bindCode;
321
+ }
322
+ $('#userBadgeBox').innerHTML = `${esc(currentUser.companyName)} (ИНН ${esc(currentUser.inn || '—')}) · лицензия <b>${esc(currentUser.licenseId)}</b> · ПЭП <code>${esc(currentUser.ofertaSignatureId || '—')}</code>`;
323
+ updateOfertaBanners();
324
+ updateClientSupportCard();
167
325
  moveIndicator();
168
326
  recalcAddons();
169
327
  }
@@ -215,54 +373,97 @@
215
373
  recalcAddons();
216
374
  }
217
375
  function recalcBaseOrder() {
376
+ const variant = $('#segPakVariant button.on')?.dataset.variant || 'pak';
377
+ const isLicenseOnly = variant === 'license_only';
378
+ const basePrice = isLicenseOnly ? (D.product.baseLicensePriceRub || 4_000_000) : D.product.basePriceRub;
218
379
  const extra = Math.max(0, Number($('#inpPakExtraSeats').value) || 0);
219
380
  const tier = tierById(segValue('segPakTier'));
220
381
  const disc = volumeDiscount(extra);
221
382
  const gross = extra * tier.pricePerUserYearRub;
222
383
  const net = gross - Math.round(gross * disc.discountPercent / 100);
223
384
  $('#lblPakSeats').textContent = extra;
224
- $('#lblPakTotal').textContent = rub(D.product.basePriceRub + net);
385
+ $('#lblPakTotal').textContent = rub(basePrice + net);
225
386
  const rows = [
226
- ['Комплекс ODANT-WORK', rub(D.product.basePriceRub)],
387
+ [isLicenseOnly ? 'Базовая лицензия (без железа)' : 'Комплекс ODANT-WORK (с машиной)', rub(basePrice)],
227
388
  ['Вечные рабочие места', String(D.product.baseSeats)],
228
389
  ['Поддержка в комплекте', '1 год · Enterprise (далее — по желанию)'],
229
390
  ];
230
391
  if (extra > 0) rows.push([`Доп. места · ${esc(tier.name)}${disc.discountPercent ? ' · −' + disc.discountPercent + '%' : ''}`, `${extra} · ${rub(net)}`]);
231
392
  $('#lblPakBreakdown').innerHTML = lines(rows);
232
393
  }
233
- async function submitBasePakOrder() {
394
+ async function submitBasePakOrder(paymentMethod = 'yookassa') {
395
+ if (!isUserReadyForOrders()) {
396
+ requireAuthAndOferta(() => submitBasePakOrder(paymentMethod), {
397
+ companyName: $('#inpPakOrg')?.value.trim(),
398
+ inn: $('#inpPakInn')?.value.trim(),
399
+ email: $('#inpPakEmail')?.value.trim(),
400
+ });
401
+ return;
402
+ }
403
+ const variant = $('#segPakVariant button.on')?.dataset.variant || 'pak';
404
+ const isLicenseOnly = variant === 'license_only';
234
405
  const st = $('#pakOrderStatus');
235
- st.textContent = 'Формируем счёт ЮKassa…';
406
+ st.textContent = paymentMethod === 'invoice' ? 'Формируем счёт-договор для юр. лица…' : 'Формируем счёт ЮKassa…';
236
407
  try {
237
408
  const data = await api('/api/orders/checkout', {
238
- includeBasePak: true,
409
+ sessionKey: currentUser?.sessionKey || '',
410
+ includeBasePak: !isLicenseOnly,
411
+ includeBaseLicenseOnly: isLicenseOnly,
412
+ paymentMethod,
239
413
  tier: segValue('segPakTier'),
240
414
  addonSeats: Number($('#inpPakExtraSeats').value) || 0,
241
415
  years: 1,
242
416
  customer: { name: $('#inpPakOrg').value.trim(), inn: $('#inpPakInn').value.trim(), email: $('#inpPakEmail').value.trim() },
243
417
  });
244
- st.innerHTML = `Счёт <b>${esc(data.orderId)}</b> сформирован, лицензия <b>${esc(data.licenseId)}</b>. <a href="${esc(data.confirmationUrl)}" target="_blank" rel="noopener">Перейти к оплате →</a>`;
245
- refreshRegistry();
418
+ if (paymentMethod === 'invoice') {
419
+ st.innerHTML = `Счёт-договор <b>${esc(data.invoiceNumber || data.orderId)}</b> сформирован, лицензия <b>${esc(data.licenseId)}</b>. <a href="${esc(data.invoiceUrl || data.confirmationUrl)}" target="_blank" rel="noopener">Открыть печатную форму счёта →</a>`;
420
+ }
421
+ else {
422
+ st.innerHTML = `Счёт <b>${esc(data.orderId)}</b> сформирован, лицензия <b>${esc(data.licenseId)}</b>. <a href="${esc(data.confirmationUrl)}" target="_blank" rel="noopener">Перейти к оплате →</a>`;
423
+ }
424
+ refreshCatalogRegions();
246
425
  }
247
426
  catch (err) { st.textContent = err.message; }
248
427
  }
249
- async function submitAddonOrder() {
428
+ async function submitAddonOrder(paymentMethod = 'yookassa') {
429
+ if (!isUserReadyForOrders()) {
430
+ requireAuthAndOferta(() => submitAddonOrder(paymentMethod));
431
+ return;
432
+ }
250
433
  const st = $('#orderStatus');
251
- st.textContent = 'Формируем счёт ЮKassa…';
434
+ st.textContent = paymentMethod === 'invoice' ? 'Формируем счёт-договор для юр. лица…' : 'Формируем счёт ЮKassa…';
252
435
  try {
253
436
  const data = await api('/api/orders/checkout', {
437
+ sessionKey: currentUser?.sessionKey || '',
254
438
  includeBasePak: false,
439
+ paymentMethod,
255
440
  tier: segValue('segTier'),
256
441
  addonSeats: Number($('#inpSeats').value) || 1,
257
442
  years: Number($('#inpYears').value) || 1,
258
443
  licenseId: $('#inpLicId').value.trim(),
259
444
  customer: { name: currentUser?.companyName || '', inn: currentUser?.inn || '', email: currentUser?.email || '' },
260
445
  });
261
- st.innerHTML = `Заказ <b>${esc(data.orderId)}</b> на <b>${rub(data.amountRub)}</b>. <a href="${esc(data.confirmationUrl)}" target="_blank" rel="noopener">Перейти к оплате →</a>`;
262
- refreshRegistry();
446
+ if (paymentMethod === 'invoice') {
447
+ st.innerHTML = `Счёт-договор <b>${esc(data.invoiceNumber || data.orderId)}</b> на <b>${rub(data.amountRub)}</b>. <a href="${esc(data.invoiceUrl || data.confirmationUrl)}" target="_blank" rel="noopener">Открыть счёт на оплату →</a>`;
448
+ }
449
+ else {
450
+ st.innerHTML = `Заказ <b>${esc(data.orderId)}</b> на <b>${rub(data.amountRub)}</b>. <a href="${esc(data.confirmationUrl)}" target="_blank" rel="noopener">Перейти к оплате →</a>`;
451
+ }
452
+ refreshCatalogRegions();
263
453
  }
264
454
  catch (err) { st.textContent = err.message; }
265
455
  }
456
+ async function generateCabinetToken() {
457
+ if (!currentUser?.sessionKey) return;
458
+ try {
459
+ const res = await api('/api/cabinet/token', { sessionKey: currentUser.sessionKey });
460
+ if (res?.token) {
461
+ currentUser.hubToken = res.token;
462
+ if ($('#inpCabinetHubToken')) $('#inpCabinetHubToken').value = res.token;
463
+ }
464
+ }
465
+ catch (err) { alert(err.message); }
466
+ }
266
467
 
267
468
  function recalcPartner() {
268
469
  const m = Number($('#rngPartnerSales').value) || 0;
@@ -641,10 +842,19 @@
641
842
  code = code || selectedCode;
642
843
  if (!code) return;
643
844
  selectRegion(code);
845
+ if (!isUserReadyForOrders()) {
846
+ requireAuthAndOferta(() => reserveRegionFranchise(code), {
847
+ companyName: $('#inpPartnerCompany')?.value.trim(),
848
+ inn: $('#inpPartnerInn')?.value.trim(),
849
+ email: $('#inpPartnerEmail')?.value.trim(),
850
+ });
851
+ return;
852
+ }
644
853
  const msg = $('#partnerActionMsg');
645
854
  msg.textContent = 'Оформляем резерв и счёт на залог…';
646
855
  try {
647
856
  const data = await api('/api/partners/reserve', {
857
+ sessionKey: currentUser?.sessionKey || '',
648
858
  regionCode: code,
649
859
  companyName: $('#inpPartnerCompany').value.trim(),
650
860
  inn: $('#inpPartnerInn').value.trim(),
@@ -655,283 +865,47 @@
655
865
  renderRegionsTable();
656
866
  selectRegion(code);
657
867
  msg.innerHTML = `Квота в субъекте «${esc(data.reservation.regionName)}» закреплена за вами (резерв <b>${esc(data.reservation.reservationId)}</b>). <a href="${esc(data.confirmationUrl)}" target="_blank" rel="noopener">Оплатить залог ${rub(data.amountRub)} →</a>`;
658
- refreshRegistry();
868
+ refreshCatalogRegions();
659
869
  }
660
870
  catch (err) { msg.textContent = err.message; }
661
871
  }
662
872
  window.reserveRegionFranchise = reserveRegionFranchise;
663
873
 
664
- /* ================= Поддержка ================= */
874
+ /* ================= Поддержка и синхронизация квот ================= */
665
875
  async function sendBot() {
666
876
  const box = $('#botReplyBox');
667
877
  try {
668
878
  const data = await api('/api/bots/' + $('#botChannel').value + '/webhook', {
669
- chat_id: $('#botChatId').value.trim(), text: $('#botText').value, user_name: 'Клиент ODANT-WORK',
879
+ chat_id: $('#botChatId').value.trim(),
880
+ text: $('#botText').value,
881
+ user_name: currentUser?.companyName || 'Клиент ODANT-WORK',
670
882
  });
671
883
  box.textContent = data.reply || data.result?.reply || JSON.stringify(data, null, 2);
672
884
  }
673
885
  catch (err) { box.textContent = err.message; }
674
- refreshRegistry();
675
- }
676
- async function createDemoPak() {
677
- try {
678
- const lic = await api('/api/license/issue', { customer: { name: 'ООО «Маслоторг»', inn: '7701234567', email: 'admin@maslotorg.ru' } });
679
- if (lic.licenseId) $('#botText').value = '/bind ' + lic.licenseId;
680
- }
681
- catch { /* показываем реестр как есть */ }
682
- refreshRegistry();
683
- }
684
- let lastAdminData = null;
685
-
686
- async function refreshRegistry() {
687
- let data;
688
- try { data = await api('/api/admin/overview'); }
689
- catch { return; }
690
- lastAdminData = data;
691
- if (Array.isArray(data.regions) && data.regions.length) {
692
- data.regions.forEach(mergeRegion);
693
- paintMap();
694
- renderRegionsTable();
695
- if (selectedCode) selectRegion(selectedCode);
696
- }
697
- const out = [];
698
- out.push(`Лицензии (${data.licenses?.length || 0})`);
699
- for (const l of data.licenses || [])
700
- out.push(` ${l.licenseId} · ${l.customer?.name || '—'} · мест ${l.totalSeats} (${l.baseSeats ?? 10} вечных + ${l.addonSeats}) · ${l.support?.tierName || ''}`);
701
- out.push('', `Резервы франшиз (${data.reservations?.length || 0})`);
702
- for (const r of data.reservations || [])
703
- out.push(` ${r.reservationId} · ${r.regionName} · ${r.companyName} · залог ${r.depositPaid ? 'оплачен' : 'ожидает оплаты'}`);
704
- out.push('', `Обращения (${data.tickets?.length || 0})`);
705
- for (const t of data.tickets || [])
706
- out.push(` ${t.ticketId} [${String(t.channel).toUpperCase()}] ${t.tierName}: ${t.messages?.[0]?.text || ''}`);
707
- if ($('#registryBox')) $('#registryBox').textContent = out.join('\n');
708
- renderAdminPanel(data);
709
- }
710
-
711
- function fmtDate(ms) {
712
- if (!ms) return '—';
713
- try { return new Date(ms).toLocaleDateString('ru-RU'); } catch { return '—'; }
714
- }
715
-
716
- function renderAdminPanel(data = {}) {
717
- const licenses = data.licenses || [];
718
- const orders = data.orders || [];
719
- const partners = data.partners || [];
720
- const reservations = data.reservations || [];
721
- const tickets = data.tickets || [];
722
-
723
- const totalSeats = licenses.reduce((s, l) => s + (l.totalSeats || 0), 0);
724
- const paidOrders = orders.filter(o => o.status === 'paid');
725
- const pendingOrders = orders.filter(o => o.status === 'pending');
726
- const paidRub = paidOrders.reduce((s, o) => s + (Number(o.amountRub) || 0), 0);
727
- const pendingRub = pendingOrders.reduce((s, o) => s + (Number(o.amountRub) || 0), 0);
728
- const openTickets = tickets.filter(t => t.status !== 'answered' && t.status !== 'closed');
729
-
730
- if ($('#admStatLic')) {
731
- $('#admStatLic').textContent = String(licenses.length);
732
- $('#admStatLicSub').textContent = `лицензий ПАК · ${totalSeats} активных мест`;
733
- $('#admStatPaid').textContent = rub(paidRub);
734
- $('#admStatPaidSub').textContent = `оплачено заказов: ${paidOrders.length}`;
735
- $('#admStatPending').textContent = rub(pendingRub);
736
- $('#admStatPendingSub').textContent = `ожидают оплаты: ${pendingOrders.length}`;
737
- $('#admStatPartners').textContent = `${partners.length} / ${reservations.length}`;
738
- $('#admStatTickets').textContent = String(openTickets.length);
739
- $('#admStatTicketsSub').textContent = `открытых из ${tickets.length} обращений`;
740
- }
741
-
742
- if ($('#admLicensesBody')) {
743
- $('#admLicensesBody').innerHTML = licenses.map(l => `<tr>
744
- <td><b>${esc(l.licenseId)}</b><div class="small muted">Код бота: <code>${esc(l.bindingCode || l.licenseId)}</code></div></td>
745
- <td><b>${esc(l.customer?.name || '—')}</b><div class="small muted">ИНН ${esc(l.customer?.inn || '—')} · ${esc(l.customer?.email || '—')}</div></td>
746
- <td><code>${esc(l.boundHwid || 'не привязан')}</code><div class="small muted">${esc((l.boundNodeFingerprint || '').slice(0, 18) || 'ожидает синхронизации')}</div></td>
747
- <td class="num"><b>${l.totalSeats}</b> <span class="muted">(${l.baseSeats ?? 10} вечн. + ${l.addonSeats || 0} доп.)</span></td>
748
- <td><span class="tag ok">${esc(l.support?.tierName || 'Enterprise')}</span><div class="small muted">до ${fmtDate(l.support?.expiresAt)}</div></td>
749
- <td><button class="btn btn-ghost btn-sm" data-cert="${esc(l.licenseId)}">Сертификат JSON</button></td>
750
- </tr>`).join('') || '<tr><td colspan="6" class="muted">Лицензий пока нет — нажмите «+ Демо-лицензия ПАК»</td></tr>';
751
- }
752
-
753
- if ($('#admOrdersBody')) {
754
- $('#admOrdersBody').innerHTML = orders.map(o => {
755
- const stTag = o.status === 'paid' ? 'ok' : (o.status === 'canceled' ? 'bad' : '');
756
- const stLabel = o.status === 'paid' ? 'оплачен' : (o.status === 'canceled' ? 'отменён' : 'ожидает оплаты');
757
- return `<tr>
758
- <td><b>${esc(o.orderId)}</b><div class="small muted">${esc(o.paymentId || '—')}</div></td>
759
- <td><b>${esc(o.tierName || o.kind || 'Заказ')}</b><div class="small muted">${o.includeBasePak ? 'Комплекс ПАК' : (o.kind === 'franchise_reservation' ? 'Залог франшизы' : `Доп. места: ${o.addonSeats || 0} × ${o.years || 1} г.`)}</div></td>
760
- <td><b>${esc(o.customer?.name || '—')}</b><div class="small muted">ИНН ${esc(o.customer?.inn || '—')}</div></td>
761
- <td class="num"><b>${rub(o.amountRub)}</b>${o.volumeDiscountPercent ? `<div class="small" style="color:var(--ok)">скидка −${o.volumeDiscountPercent}%</div>` : ''}</td>
762
- <td><span class="tag ${stTag}">${stLabel}</span></td>
763
- <td><div style="display:flex;gap:6px;flex-wrap:wrap">
764
- ${o.status === 'pending' ? `<button class="btn btn-ok btn-sm" data-pay-order="${esc(o.orderId)}">Провести оплату</button>` : ''}
765
- <a class="btn btn-ghost btn-sm" href="/pay/sandbox/${encodeURIComponent(o.orderId)}" target="_blank" rel="noopener">Песочница</a>
766
- </div></td>
767
- </tr>`;
768
- }).join('') || '<tr><td colspan="6" class="muted">Заказов пока нет</td></tr>';
769
- }
770
-
771
- if ($('#admPartnersBody')) {
772
- const resRows = reservations.map(r => `<tr>
773
- <td><b>${esc(r.reservationId)}</b><div class="small muted">Резерв франшизы</div></td>
774
- <td><b>${esc(r.regionName)}</b> <span class="muted">(${esc(r.regionCode)})</span></td>
775
- <td><b>${esc(r.companyName)}</b><div class="small muted">ИНН ${esc(r.inn || '—')} · ${esc(r.email || '—')}</div></td>
776
- <td>Паушальный взнос <b>${rub(r.franchiseFeeRub)}</b><div class="small muted">Залог ${rub(r.nonRefundableDepositRub || r.depositAmountRub || D.franchise.reservationDepositRub)} · полная оплата за 1 год</div></td>
777
- <td><span class="tag ${r.depositPaid ? 'ok' : ''}">${r.depositPaid ? 'залог оплачен' : 'ждёт оплаты залога'}</span></td>
778
- <td><div style="display:flex;gap:6px;flex-wrap:wrap">
779
- ${!r.depositPaid && r.orderId ? `<button class="btn btn-ok btn-sm" data-pay-order="${esc(r.orderId)}">Оплатить залог</button>` : ''}
780
- ${!r.convertedToPartnerId ? `<button class="btn btn-primary btn-sm" data-activate-res="${esc(r.reservationId)}">Активировать партнёра</button>` : `<span class="small muted">Партнёр ${esc(r.convertedToPartnerId)}</span>`}
781
- </div></td>
782
- </tr>`);
783
- const partRows = partners.map(p => `<tr>
784
- <td><b>${esc(p.partnerId)}</b><div class="small muted">Действующий партнёр</div></td>
785
- <td><b>${esc(p.regionName)}</b> <span class="muted">(${esc(p.regionCode)})</span></td>
786
- <td><b>${esc(p.companyName)}</b><div class="small muted">ИНН ${esc(p.inn || '—')}</div></td>
787
- <td>Скидка <b>${p.discountPercent}%</b> · ${esc(p.statusLevel)}<div class="small muted">Продажи: ${rub(p.annualSalesRub || 0)}</div></td>
788
- <td><span class="tag ok">сертификатов: ${(p.certifiedSpecialists || []).length}</span></td>
789
- <td><button class="btn btn-ghost btn-sm" data-certify-partner="${esc(p.partnerId)}">+ Аттестовать инженера</button></td>
790
- </tr>`);
791
- $('#admPartnersBody').innerHTML = [...resRows, ...partRows].join('') || '<tr><td colspan="6" class="muted">Резервов и партнёров пока нет — выберите субъект во вкладке «Франшиза»</td></tr>';
792
- }
793
-
794
- if ($('#admTicketsBody')) {
795
- $('#admTicketsBody').innerHTML = tickets.map(t => {
796
- const msgs = (t.messages || []).map(m => `<div class="small"><b>${esc(m.role === 'engineer' ? (m.author || 'Инженер') : (t.userName || 'Клиент'))}:</b> ${esc(m.text)}</div>`).join('');
797
- return `<tr>
798
- <td><b>${esc(t.ticketId)}</b><div><span class="tag">${esc(String(t.channel || '').toUpperCase())}</span></div></td>
799
- <td><b>${esc(t.userName || t.chatId)}</b><div class="small muted">Лицензия: ${esc(t.licenseId || 'не привязана')}</div></td>
800
- <td><span class="tag ok">${esc(t.tierName || 'Start')}</span><div class="small muted">реакция ≤ ${t.slaReactionMinutes || 480} мин</div></td>
801
- <td style="white-space:normal;max-width:360px">${msgs}</td>
802
- <td>
803
- <div style="display:flex;gap:6px">
804
- <input type="text" id="reply-${esc(t.ticketId)}" placeholder="Ответ инженера…" style="min-width:190px;padding:7px 10px;font-size:12.5px">
805
- <button class="btn btn-primary btn-sm" data-reply-ticket="${esc(t.ticketId)}">Ответить</button>
806
- </div>
807
- </td>
808
- </tr>`;
809
- }).join('') || '<tr><td colspan="5" class="muted">Обращений пока нет — отправьте сообщение через «Консоль бота» во вкладке «Поддержка»</td></tr>';
810
- }
811
- }
812
-
813
- async function adminIssueLicense() {
814
- const st = $('#admIssueStatus');
815
- st.textContent = 'Выпускаем сертификат…';
816
- try {
817
- const lic = await api('/api/license/issue', {
818
- hwid: $('#admLicHwid').value.trim(),
819
- customer: {
820
- name: $('#admLicOrg').value.trim(),
821
- inn: $('#admLicInn').value.trim(),
822
- email: $('#admLicEmail').value.trim(),
823
- },
824
- });
825
- st.textContent = `Выпущена лицензия ${lic.licenseId} (${lic.totalSeats} мест, подпись Ed25519)`;
826
- refreshRegistry();
827
- }
828
- catch (err) { st.textContent = err.message; }
829
- }
830
-
831
- async function adminConfirmOrder(orderId) {
832
- const order = (lastAdminData?.orders || []).find(o => o.orderId === orderId);
833
- if (!order) return;
834
- await api('/api/payments/yookassa/webhook', {
835
- type: 'notification',
836
- event: 'payment.succeeded',
837
- object: {
838
- id: order.paymentId,
839
- status: 'succeeded',
840
- amount: { value: Number(order.amountRub || 0).toFixed(2), currency: 'RUB' },
841
- metadata: { orderId: order.orderId, licenseId: order.licenseId || '', reservationId: order.reservationId || '' },
842
- },
843
- });
844
- refreshRegistry();
845
- }
846
-
847
- async function adminActivateReservation(reservationId) {
848
- const r = (lastAdminData?.reservations || []).find(x => x.reservationId === reservationId);
849
- if (!r) return;
850
- try {
851
- await api('/api/partners/register', {
852
- reservationId: r.reservationId,
853
- regionCode: r.regionCode,
854
- companyName: r.companyName,
855
- inn: r.inn,
856
- email: r.email,
857
- franchiseFeePaid: true,
858
- });
859
- refreshRegistry();
886
+ if (currentUser?.sessionKey) {
887
+ try {
888
+ const me = await api('/api/auth/me?sessionKey=' + encodeURIComponent(currentUser.sessionKey));
889
+ if (me.authenticated && me.user) {
890
+ currentUser = { ...me.user, sessionKey: currentUser.sessionKey };
891
+ updateClientSupportCard();
892
+ }
893
+ }
894
+ catch { /* ignore */ }
860
895
  }
861
- catch (err) { alert(err.message); }
862
896
  }
863
897
 
864
- async function adminCertifyPartner(partnerId) {
898
+ async function refreshCatalogRegions() {
865
899
  try {
866
- await api('/api/partners/certify', {
867
- partnerId,
868
- specialistName: 'Ведущий инженер ПАК',
869
- specialistEmail: 'engineer@partner.ru',
870
- trackCode: 'TRK-PAK-HW',
871
- examScore: 96,
872
- });
873
- refreshRegistry();
874
- }
875
- catch (err) { alert(err.message); }
876
- }
877
-
878
- async function adminReplyTicket(ticketId) {
879
- const inp = document.getElementById('reply-' + ticketId);
880
- const text = inp?.value.trim();
881
- if (!text) return;
882
- await api('/api/tickets/' + encodeURIComponent(ticketId) + '/reply', {
883
- engineerName: 'Дежурный инженер ODANT',
884
- text,
885
- });
886
- inp.value = '';
887
- refreshRegistry();
888
- }
889
-
890
- function openCertModal(licenseId) {
891
- const lic = (lastAdminData?.licenses || []).find(l => l.licenseId === licenseId);
892
- if (!lic) return;
893
- $('#certTitle').textContent = `Сертификат ${lic.licenseId}`;
894
- $('#certSub').textContent = `${lic.customer?.name || ''} · Ed25519 (WORK-LIC-v1)`;
895
- $('#certJsonBox').textContent = JSON.stringify(lic.certificate || lic, null, 2);
896
- $('#certModal').classList.add('open');
897
- }
898
-
899
- function switchAdminTab(tab = 'all') {
900
- $$('[data-adm-tab]').forEach(b => b.classList.toggle('on', b.dataset.admTab === tab));
901
- const ws = $('#adminWorkspace');
902
- if (ws) ws.classList.toggle('only-agent', tab === 'agent');
903
- $$('[data-adm-sec]').forEach(sec => {
904
- sec.style.display = (tab === 'all' || tab === sec.dataset.admSec) ? '' : 'none';
905
- });
906
- if (tab === 'agent') setTimeout(() => $('#admAiInput')?.focus(), 50);
907
- }
908
-
909
- async function sendAdminAi(text) {
910
- const inp = $('#admAiInput');
911
- text = (text ?? inp?.value ?? '').trim();
912
- if (!text) return;
913
- if (inp) inp.value = '';
914
- const box = $('#admAiMessages');
915
- if (!box) return;
916
- box.insertAdjacentHTML('beforeend', `<div class="msg me">${esc(text)}</div><div class="msg bot" id="admAiTyping"><span class="typing"><i></i><i></i><i></i></span></div>`);
917
- box.scrollTop = box.scrollHeight;
918
- let data;
919
- try { data = await api('/api/agent/chat', { message: text, sessionKey: currentUser?.sessionKey || '' }); }
920
- catch (err) { data = { reply: err.message, actions: [] }; }
921
- $('#admAiTyping')?.remove();
922
- box.insertAdjacentHTML('beforeend', `<div class="msg bot">${md(data.reply || '')}</div>`);
923
- box.scrollTop = box.scrollHeight;
924
- for (const act of data.actions || []) {
925
- if (act.type === 'refresh_admin') {
926
- refreshRegistry();
927
- }
928
- else if (act.type === 'admin_tab' && act.tab) {
929
- switchAdminTab(act.tab);
930
- }
931
- else if (act.type === 'select_region' && act.regionCode) {
932
- selectRegion(act.regionCode, { zoom: true, scrollTable: false });
900
+ const data = await api('/api/catalog');
901
+ if (Array.isArray(data.regions) && data.regions.length) {
902
+ data.regions.forEach(mergeRegion);
903
+ paintMap();
904
+ renderRegionsTable();
905
+ if (selectedCode) selectRegion(selectedCode);
933
906
  }
934
907
  }
908
+ catch { /* ignore */ }
935
909
  }
936
910
 
937
911
  /* ================= ИИ-агент лицензионного центра ================= */
@@ -962,21 +936,24 @@
962
936
  box.insertAdjacentHTML('beforeend', `<div class="msg bot">${md(data.reply || '')}</div>`);
963
937
  box.scrollTop = box.scrollHeight;
964
938
  for (const act of data.actions || []) {
965
- if (act.type === 'refresh_admin') {
966
- refreshRegistry();
967
- }
968
- else if (act.type === 'select_region') {
939
+ if (act.type === 'select_region') {
969
940
  switchStage('franchise');
970
941
  setTimeout(() => selectRegion(act.regionCode, { zoom: true, scrollTable: true }), 80);
971
942
  }
972
- else if (act.type === 'prefill_calculator' && currentUser) {
943
+ else if (act.type === 'prefill_calculator' && isUserReadyForOrders()) {
973
944
  $$('#segTier button').forEach(b => b.classList.toggle('on', b.dataset.tier === act.tier));
974
945
  if (act.years) $('#inpYears').value = act.years;
975
946
  syncSeats(act.seats);
976
947
  switchStage('addons');
977
948
  }
978
- else if (act.type === 'switch_stage' && act.stage) {
979
- switchStage(act.stage === 'addons' && !currentUser ? 'pricing' : act.stage);
949
+ else if (act.type === 'open_auth') {
950
+ openAuth();
951
+ }
952
+ else if (act.type === 'open_oferta') {
953
+ openOfertaModal();
954
+ }
955
+ else if (act.type === 'switch_stage' && act.stage && act.stage !== 'admin') {
956
+ switchStage(act.stage === 'addons' && !isUserReadyForOrders() ? 'pricing' : act.stage);
980
957
  }
981
958
  }
982
959
  }
@@ -1146,16 +1123,9 @@
1146
1123
  /* ================= Привязка событий ================= */
1147
1124
  function bind() {
1148
1125
  document.addEventListener('click', (e) => {
1149
- const el = e.target.closest('[data-stage],[data-action],[data-reserve],[data-bot],[data-ask],[data-adm-ask],[data-adm-tab],[data-district],[data-cert],[data-pay-order],[data-activate-res],[data-certify-partner],[data-reply-ticket],th[data-sort],#regionsTableBody tr');
1126
+ const el = e.target.closest('[data-stage],[data-action],[data-reserve],[data-bot],[data-ask],[data-district],th[data-sort],#regionsTableBody tr');
1150
1127
  if (!el) return;
1151
1128
  if (el.matches('[data-reserve]')) { e.stopPropagation(); reserveRegionFranchise(el.dataset.reserve); return; }
1152
- if (el.matches('[data-adm-tab]')) { switchAdminTab(el.dataset.admTab); return; }
1153
- if (el.matches('[data-adm-ask]')) { sendAdminAi(el.dataset.admAsk); return; }
1154
- if (el.matches('[data-cert]')) { openCertModal(el.dataset.cert); return; }
1155
- if (el.matches('[data-pay-order]')) { adminConfirmOrder(el.dataset.payOrder); return; }
1156
- if (el.matches('[data-activate-res]')) { adminActivateReservation(el.dataset.activateRes); return; }
1157
- if (el.matches('[data-certify-partner]')) { adminCertifyPartner(el.dataset.certifyPartner); return; }
1158
- if (el.matches('[data-reply-ticket]')) { adminReplyTicket(el.dataset.replyTicket); return; }
1159
1129
  if (el.matches('#regionsTableBody tr')) { selectRegion(el.dataset.code, { zoom: true }); $('#mapBox').scrollIntoView({ block: 'nearest', behavior: 'smooth' }); return; }
1160
1130
  if (el.matches('th[data-sort]')) {
1161
1131
  SORT.dir = SORT.key === el.dataset.sort ? -SORT.dir : (el.dataset.sort === 'name' || el.dataset.sort === 'district' ? 1 : -1);
@@ -1168,23 +1138,35 @@
1168
1138
  if (el.matches('[data-ask]')) { sendAi(el.dataset.ask); return; }
1169
1139
  if (el.dataset.stage) { e.preventDefault(); switchStage(el.dataset.stage); return; }
1170
1140
  switch (el.dataset.action) {
1171
- case 'auth': if (currentUser) switchStage('addons'); else openAuth(); break;
1141
+ case 'auth': openAuth(); break;
1172
1142
  case 'auth-close': closeAuth(); break;
1173
1143
  case 'login': performLogin(); break;
1174
- case 'addons': e.preventDefault(); if (currentUser) switchStage('addons'); else openAuth(); break;
1144
+ case 'logout': performLogout(); break;
1145
+ case 'open-oferta': e.preventDefault(); openOfertaModal(); break;
1146
+ case 'oferta-close': closeOfertaModal(); break;
1147
+ case 'accept-from-oferta':
1148
+ closeOfertaModal();
1149
+ if ($('#authOfertaCheck')) $('#authOfertaCheck').checked = true;
1150
+ openAuth();
1151
+ break;
1152
+ case 'addons':
1153
+ e.preventDefault();
1154
+ if (isUserReadyForOrders()) switchStage('addons');
1155
+ else requireAuthAndOferta(() => switchStage('addons'));
1156
+ break;
1175
1157
  case 'pick-tier':
1176
1158
  $$('#segTier button').forEach(b => b.classList.toggle('on', b.dataset.tier === el.dataset.tier));
1177
1159
  recalcAddons();
1178
- if (currentUser) switchStage('addons'); else openAuth();
1160
+ if (isUserReadyForOrders()) switchStage('addons');
1161
+ else requireAuthAndOferta(() => switchStage('addons'));
1179
1162
  break;
1180
- case 'order-pak': submitBasePakOrder(); break;
1181
- case 'order-addons': submitAddonOrder(); break;
1163
+ case 'order-pak': submitBasePakOrder('yookassa'); break;
1164
+ case 'order-pak-invoice': submitBasePakOrder('invoice'); break;
1165
+ case 'order-addons': submitAddonOrder('yookassa'); break;
1166
+ case 'order-addons-invoice': submitAddonOrder('invoice'); break;
1167
+ case 'gen-hub-token': generateCabinetToken(); break;
1182
1168
  case 'reserve-selected': reserveRegionFranchise(selectedCode); break;
1183
1169
  case 'bot-send': sendBot(); break;
1184
- case 'registry': refreshRegistry(); break;
1185
- case 'demo-pak': createDemoPak(); break;
1186
- case 'adm-issue-lic': adminIssueLicense(); break;
1187
- case 'adm-ai-send': sendAdminAi(); break;
1188
1170
  case 'cert-close': $('#certModal')?.classList.remove('open'); break;
1189
1171
  case 'cert-copy':
1190
1172
  navigator.clipboard?.writeText($('#certJsonBox')?.textContent || '');
@@ -1193,23 +1175,24 @@
1193
1175
  case 'ai-send': sendAi(); break;
1194
1176
  }
1195
1177
  });
1196
- $('#authModal').addEventListener('click', (e) => { if (e.target.id === 'authModal') closeAuth(); });
1178
+ $('#authModal')?.addEventListener('click', (e) => { if (e.target.id === 'authModal') closeAuth(); });
1179
+ $('#ofertaModal')?.addEventListener('click', (e) => { if (e.target.id === 'ofertaModal') closeOfertaModal(); });
1197
1180
  $('#certModal')?.addEventListener('click', (e) => { if (e.target.id === 'certModal') $('#certModal').classList.remove('open'); });
1198
1181
  document.addEventListener('keydown', (e) => {
1199
- if (e.key === 'Escape') { closeAuth(); $('#certModal')?.classList.remove('open'); toggleAi(false); }
1182
+ if (e.key === 'Escape') { closeAuth(); closeOfertaModal(); $('#certModal')?.classList.remove('open'); toggleAi(false); }
1200
1183
  });
1201
- $('#authModal').addEventListener('keydown', (e) => { if (e.key === 'Enter') performLogin(); });
1202
- $('#aiInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') sendAi(); });
1203
- $('#admAiInput')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendAdminAi(); });
1204
- $('#botText').addEventListener('keydown', (e) => { if (e.key === 'Enter') sendBot(); });
1205
- $('#rngAddonSeats').addEventListener('input', (e) => syncSeats(e.target.value));
1206
- $('#inpSeats').addEventListener('input', (e) => syncSeats(e.target.value));
1207
- $('#inpYears').addEventListener('input', recalcAddons);
1208
- $('#inpPakExtraSeats').addEventListener('input', recalcBaseOrder);
1209
- $('#rngPartnerSales').addEventListener('input', recalcPartner);
1210
- $('#inpRegionFilter').addEventListener('input', renderRegionsTable);
1184
+ $('#authModal')?.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') performLogin(); });
1185
+ $('#aiInput')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendAi(); });
1186
+ $('#botText')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendBot(); });
1187
+ $('#rngAddonSeats')?.addEventListener('input', (e) => syncSeats(e.target.value));
1188
+ $('#inpSeats')?.addEventListener('input', (e) => syncSeats(e.target.value));
1189
+ $('#inpYears')?.addEventListener('input', recalcAddons);
1190
+ $('#inpPakExtraSeats')?.addEventListener('input', recalcBaseOrder);
1191
+ $('#rngPartnerSales')?.addEventListener('input', recalcPartner);
1192
+ $('#inpRegionFilter')?.addEventListener('input', renderRegionsTable);
1211
1193
  bindSeg('segTier', recalcAddons);
1212
1194
  bindSeg('segPakTier', recalcBaseOrder);
1195
+ bindSeg('segPakVariant', recalcBaseOrder);
1213
1196
  window.addEventListener('resize', moveIndicator);
1214
1197
  window.addEventListener('hashchange', () => { const id = location.hash.slice(1); if (id && id !== currentStage) switchStage(id, { scroll: false }); });
1215
1198
  }
@@ -1223,12 +1206,12 @@
1223
1206
  recalcPartner();
1224
1207
  renderRegionsTable();
1225
1208
  initBackdrop();
1226
- const initial = D.initialStage || (location.pathname === '/admin' ? 'admin' : location.hash.slice(1));
1227
- switchStage(initial && initial !== 'addons' ? initial : 'product', { scroll: false });
1209
+ const initial = D.initialStage || location.hash.slice(1);
1210
+ switchStage(initial && initial !== 'addons' && initial !== 'admin' ? initial : 'product', { scroll: false });
1228
1211
  if (document.fonts?.ready) document.fonts.ready.then(moveIndicator);
1229
1212
  initMap().then(() => selectRegion(selectedCode || 'RU-SVE'));
1230
1213
  restoreSession();
1231
- refreshRegistry();
1214
+ refreshCatalogRegions();
1232
1215
  }
1233
1216
 
1234
1217
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);