work-license-server 1.0.7 → 1.0.8

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
@@ -80,7 +80,7 @@
80
80
  if (scroll) window.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' });
81
81
  if (window.setBackdropStage) window.setBackdropStage(id);
82
82
  observeReveals();
83
- if (id === 'agents') playConsultantMock();
83
+ if (id === 'product') playConsultantMock();
84
84
  if (id === 'franchise') requestAnimationFrame(() => MAP.ready && applyView());
85
85
  }
86
86
  window.switchStage = switchStage;
@@ -129,15 +129,26 @@
129
129
  card.style.setProperty('--my', (e.clientY - r.top) + 'px');
130
130
  }, { passive: true });
131
131
 
132
- /* ================= Авторизация и подписание публичной оферты (ПЭП) ================= */
133
- function openAuth(hintMsg = '') {
132
+ /* ================= Вход / Регистрация (Email, Мессенджеры, OAuth) ================= */
133
+ function openAuth(hintMsg = '', intentTitle = '') {
134
134
  if ($('#authErrorMsg')) $('#authErrorMsg').textContent = hintMsg || '';
135
+ const intentBox = $('#authIntentNotice');
136
+ if (intentBox) {
137
+ if (intentTitle) {
138
+ intentBox.style.display = '';
139
+ intentBox.innerHTML = `Выбранная позиция: <b>${esc(intentTitle)}</b>.<br>После входа она автоматически появится как заказ в вашем Личном кабинете.`;
140
+ }
141
+ else {
142
+ intentBox.style.display = 'none';
143
+ intentBox.textContent = '';
144
+ }
145
+ }
135
146
  if ($('#btnLogoutModal')) $('#btnLogoutModal').style.display = currentUser ? '' : 'none';
136
- $('#authModal').classList.add('open');
147
+ $('#authModal')?.classList.add('open');
137
148
  setTimeout(() => $('#authEmail')?.focus(), 50);
138
149
  }
139
150
  function closeAuth() {
140
- $('#authModal').classList.remove('open');
151
+ $('#authModal')?.classList.remove('open');
141
152
  if ($('#authErrorMsg')) $('#authErrorMsg').textContent = '';
142
153
  }
143
154
  function openOfertaModal() {
@@ -146,49 +157,38 @@
146
157
  function closeOfertaModal() {
147
158
  $('#ofertaModal')?.classList.remove('open');
148
159
  }
149
- function requireAuthAndOferta(actionFn, prefill = {}) {
160
+ function requireAuthAndOferta(actionFn, opts = {}) {
150
161
  if (isUserReadyForOrders()) {
151
162
  if (typeof actionFn === 'function') actionFn();
152
163
  return true;
153
164
  }
154
165
  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('Для оформления заказа, оплаты или резервирования франшизы зарегистрируйтесь и подпишите договор-оферту (ПЭП).');
166
+ openAuth('', opts.intentTitle || '');
159
167
  return false;
160
168
  }
161
169
 
162
- async function performLogin() {
170
+ async function performLogin(provider = 'email') {
163
171
  const errBox = $('#authErrorMsg');
164
172
  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);
173
+ let email = $('#authEmail')?.value.trim() || '';
174
+ const ofertaChecked = $('#authOfertaCheck') ? Boolean($('#authOfertaCheck').checked) : true;
171
175
 
172
- if (!email || !email.includes('@')) {
173
- if (errBox) errBox.textContent = 'Укажите корректный корпоративный email.';
176
+ if (!ofertaChecked) {
177
+ if (errBox) errBox.textContent = 'Подтвердите согласие с условиями Публичного договора-оферты.';
174
178
  return;
175
179
  }
176
- if (!companyName || !inn) {
177
- if (errBox) errBox.textContent = 'Укажите наименование организации и ИНН.';
180
+ if (provider === 'email' && (!email || !email.includes('@'))) {
181
+ if (errBox) errBox.textContent = 'Укажите корректный email.';
178
182
  return;
179
183
  }
180
- if (!ofertaChecked) {
181
- if (errBox) errBox.textContent = 'Для продолжения необходимо ознакомиться с условиями Публичной оферты и поставить отметку о подписании ПЭП.';
182
- return;
184
+ if (provider !== 'email' && (!email || !email.includes('@'))) {
185
+ email = `user.${provider}@${provider}.id.odant.org`;
183
186
  }
184
187
 
185
188
  try {
186
189
  const data = await api('/api/auth/register', {
187
190
  email,
188
- companyName,
189
- inn,
190
- name: signer || companyName,
191
- licenseId,
191
+ provider,
192
192
  ofertaAccepted: true,
193
193
  });
194
194
  currentUser = data;
@@ -198,7 +198,7 @@
198
198
  const nextAction = pendingAuthAction;
199
199
  pendingAuthAction = null;
200
200
  if (typeof nextAction === 'function') {
201
- nextAction();
201
+ await nextAction();
202
202
  }
203
203
  else {
204
204
  switchStage('addons');
@@ -213,11 +213,9 @@
213
213
  currentUser = null;
214
214
  pendingAuthAction = null;
215
215
  try { localStorage.removeItem('odant_work_session'); } catch { /* ignore */ }
216
- $('#tileAddons').style.display = 'none';
217
- $('#btnAuthText').textContent = 'Войти / Регистрация';
216
+ if ($('#tileAddons')) $('#tileAddons').style.display = 'none';
217
+ if ($('#btnAuthText')) $('#btnAuthText').textContent = 'Войти / Регистрация';
218
218
  if ($('#btnLogoutModal')) $('#btnLogoutModal').style.display = 'none';
219
- updateOfertaBanners();
220
- updateClientSupportCard();
221
219
  closeAuth();
222
220
  if (currentStage === 'addons') switchStage('product');
223
221
  moveIndicator();
@@ -226,11 +224,7 @@
226
224
  async function restoreSession() {
227
225
  let key = '';
228
226
  try { key = localStorage.getItem('odant_work_session') || ''; } catch { /* приватный режим */ }
229
- if (!key) {
230
- updateOfertaBanners();
231
- updateClientSupportCard();
232
- return;
233
- }
227
+ if (!key) return;
234
228
  try {
235
229
  const data = await api('/api/auth/me?sessionKey=' + encodeURIComponent(key));
236
230
  if (data.authenticated && data.user) {
@@ -240,98 +234,74 @@
240
234
  }
241
235
  }
242
236
  catch { /* сессия устарела */ }
243
- updateOfertaBanners();
244
- updateClientSupportCard();
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
237
  }
300
238
 
301
239
  function onAuthenticated() {
302
240
  if (!currentUser) return;
303
- $('#tileAddons').style.display = isUserReadyForOrders() ? '' : 'none';
304
- $('#btnAuthText').textContent = currentUser.companyName || currentUser.email;
305
- if ($('#inpLicId')) $('#inpLicId').value = currentUser.licenseId || '';
241
+ if ($('#tileAddons')) $('#tileAddons').style.display = isUserReadyForOrders() ? '' : 'none';
242
+ if ($('#btnAuthText')) $('#btnAuthText').textContent = currentUser.companyName || currentUser.email;
306
243
  if ($('#inpCabinetHubToken')) $('#inpCabinetHubToken').value = currentUser.hubToken || '';
307
244
  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
245
  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
- if (currentUser.license?.baseActivated && $('#segCabinetBase')) {
324
- $$('#segCabinetBase button').forEach(b => b.classList.toggle('on', b.dataset.base === 'none'));
325
- if (Number($('#inpSeats')?.value) === 0 && $('#inpSeats')) {
326
- $('#inpSeats').value = 5;
327
- if ($('#rngAddonSeats')) $('#rngAddonSeats').value = 5;
328
- }
246
+ if ($('#userBadgeBox')) {
247
+ const orgInfo = currentUser.companyName ? `${esc(currentUser.companyName)}${currentUser.inn ? ' (ИНН ' + esc(currentUser.inn) + ')' : ''} · ` : '';
248
+ $('#userBadgeBox').innerHTML = `${orgInfo}Аккаунт: <b>${esc(currentUser.email)}</b> · <a href="#" data-action="logout" style="color:var(--muted)">Выйти</a>`;
329
249
  }
250
+ renderCabinetOrders();
330
251
  renderCabinetLicenses();
331
- updateOfertaBanners();
332
- updateClientSupportCard();
333
252
  moveIndicator();
334
- recalcAddons();
253
+ }
254
+
255
+ function renderCabinetOrders() {
256
+ const box = $('#cabinetOrdersBox');
257
+ if (!box) return;
258
+ const orders = currentUser?.orders || [];
259
+ if (!orders.length) {
260
+ box.innerHTML = `<div style="padding:18px;border-radius:12px;border:1px dashed var(--border);color:var(--muted);text-align:center;">
261
+ В вашем Личном кабинете пока нет заказов. Перейдите в раздел <a href="#clients" data-stage="clients"><b>«Клиентам»</b></a> (чтобы купить лицензию или ПАК) или <a href="#franchise" data-stage="franchise"><b>«Франшиза»</b></a> (чтобы зарезервировать регион).
262
+ </div>`;
263
+ return;
264
+ }
265
+ box.innerHTML = orders.map(o => {
266
+ const isPaid = o.status === 'paid';
267
+ const title = (o.items && o.items.length)
268
+ ? o.items.map(it => esc(it.title)).join(' + ')
269
+ : esc(o.tierName || o.orderId);
270
+ const dateStr = o.createdAt ? new Date(o.createdAt).toLocaleString('ru-RU') : '';
271
+ const statusBadge = isPaid
272
+ ? `<span class="tag ok">✓ Оплачен</span>`
273
+ : `<span class="tag">Ожидает оплаты</span>`;
274
+ const actionsHtml = isPaid
275
+ ? `<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
276
+ ${o.invoiceUrl ? `<a class="btn btn-ghost btn-sm" href="${esc(o.invoiceUrl)}" target="_blank" rel="noopener">Открыть счёт-договор (${esc(o.invoiceNumber || o.orderId)}) ↗</a>` : ''}
277
+ </div>`
278
+ : `<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:12px;align-items:center">
279
+ <button class="btn btn-primary btn-sm" data-pay-order="${esc(o.orderId)}" data-pay-method="yookassa">Оплатить через ЮKassa (${rub(o.amountRub)})</button>
280
+ <button class="btn btn-ghost btn-sm" data-invoice-toggle="${esc(o.orderId)}">Счёт-договор (для юр. лиц)</button>
281
+ <button class="btn btn-ghost btn-sm" data-cancel-order="${esc(o.orderId)}" style="color:var(--danger)">Удалить заказ</button>
282
+ </div>
283
+ <div id="invBox-${esc(o.orderId)}" style="display:none;margin-top:12px;padding:12px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,.02)">
284
+ <div class="small" style="margin-bottom:8px;font-weight:600">Реквизиты организации для выставления счёта-договора:</div>
285
+ <div class="row2">
286
+ <div class="field"><label>Наименование организации *</label><input type="text" id="invCompany-${esc(o.orderId)}" value="${esc(o.customer?.name || currentUser?.companyName || '')}" placeholder="ООО «Маслоторг»"></div>
287
+ <div class="field"><label>ИНН *</label><input type="text" id="invInn-${esc(o.orderId)}" value="${esc(o.customer?.inn || currentUser?.inn || '')}" placeholder="7701234567" inputmode="numeric"></div>
288
+ </div>
289
+ <button class="btn btn-primary btn-sm" style="margin-top:8px" data-pay-order="${esc(o.orderId)}" data-pay-method="invoice">Сформировать и открыть счёт-договор →</button>
290
+ </div>`;
291
+ return `<div style="padding:14px 16px;border-radius:12px;border:1px solid var(--border);margin-bottom:10px;">
292
+ <div style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:center">
293
+ <div>
294
+ <div style="font-size:12px;color:var(--muted)">Заказ <code>${esc(o.orderId)}</code> · ${esc(dateStr)}</div>
295
+ <div style="font-weight:700;font-size:15px;margin-top:2px">${title}</div>
296
+ </div>
297
+ <div style="text-align:right">
298
+ <div style="font-size:18px;font-weight:800">${rub(o.amountRub)}</div>
299
+ ${statusBadge}
300
+ </div>
301
+ </div>
302
+ ${actionsHtml}
303
+ </div>`;
304
+ }).join('');
335
305
  }
336
306
 
337
307
  function renderCabinetLicenses() {
@@ -339,9 +309,10 @@
339
309
  if (!box) return;
340
310
  const lic = currentUser?.license;
341
311
  const files = lic?.licenseFiles || [];
342
- if (!files.length) {
312
+ const reservations = (currentUser?.reservations || []).filter(r => r.depositPaid);
313
+ if (!files.length && !reservations.length) {
343
314
  box.innerHTML = `<div style="padding:12px 14px;border-radius:10px;border:1px dashed var(--border);color:var(--muted);">
344
- У вас пока нет приобретённых лицензий. Оформите базовую постоянную лицензию (или комплект с машиной) в Шаге 3 ниже — после оплаты файлы лицензий сразу появятся здесь и будут доступны для автоматической загрузки по вашему токену.
315
+ Активных оплаченных лицензий пока нет. После оплаты заказа выше подписанные файлы лицензий (<code>.lic.json</code>) сразу появятся здесь и автоматически загрузятся в ваш локальный WORK по токену владельца.
345
316
  </div>`;
346
317
  return;
347
318
  }
@@ -355,7 +326,11 @@
355
326
  <div style="text-align:right"><span class="pill" style="padding:3px 10px;font-size:11.5px">${esc(expText)}</span></div>
356
327
  </div>`;
357
328
  }).join('');
358
- box.innerHTML = `<div>Всего доступно одновременных сессий: <b style="color:var(--text)">${lic.totalSeats}</b> (${lic.baseSeats} вечных + ${lic.addonSeats} временных)</div>` + rows;
329
+ const resRows = reservations.map(r => `<div style="display:flex;justify-content:space-between;gap:12px;padding:8px 0;border-bottom:1px solid var(--border);align-items:center;">
330
+ <div><b>Региональная франшиза: ${esc(r.regionName)}</b><br><code style="font-size:11.5px;color:var(--muted)">${esc(r.reservationId)}</code></div>
331
+ <div style="text-align:right"><span class="pill" style="padding:3px 10px;font-size:11.5px">Залог оплачен</span></div>
332
+ </div>`).join('');
333
+ box.innerHTML = (files.length ? `<div>Всего доступно одновременных сессий: <b style="color:var(--text)">${lic.totalSeats}</b> (${lic.baseSeats} вечных + ${lic.addonSeats} временных)</div>` : '') + rows + resRows;
359
334
  }
360
335
 
361
336
  async function refreshCabinetUser() {
@@ -381,7 +356,7 @@
381
356
  }
382
357
  }
383
358
 
384
- /* ================= Калькуляторы ================= */
359
+ /* ================= Калькуляторы раздела «Клиентам» ================= */
385
360
  function volumeDiscount(seats) {
386
361
  const s = Math.max(0, Math.floor(Number(seats) || 0));
387
362
  return (D.discounts || []).find(d => s >= d.minSeats) || { discountPercent: 0, minSeats: 1 };
@@ -400,136 +375,147 @@
400
375
  const tierById = (id) => (D.tiers || []).find(t => t.id === id) || D.tiers[1];
401
376
  const lines = (rows) => rows.map(([k, v]) => `<div><span>${k}</span><b>${v}</b></div>`).join('');
402
377
 
403
- function recalcAddons() {
404
- const baseMode = $('#segCabinetBase button.on')?.dataset.base || 'license_only';
405
- const includeBasePak = baseMode === 'pak';
406
- const includeBaseLicenseOnly = baseMode === 'license_only';
378
+ function syncClientSeats(v) {
379
+ const variant = $('#segPakVariant button.on')?.dataset.variant || 'pak';
380
+ const minSeats = variant === 'addons_only' ? 1 : 0;
381
+ const n = Math.max(minSeats, Math.min(2000, Number(v) || 0));
382
+ if ($('#inpPakExtraSeats')) $('#inpPakExtraSeats').value = Math.min(150, n);
383
+ if ($('#inpPakExtraSeatsNum')) $('#inpPakExtraSeatsNum').value = n;
384
+ recalcBaseOrder();
385
+ }
386
+
387
+ function recalcBaseOrder() {
388
+ if (!$('#lblPakTotal')) return;
389
+ const variant = $('#segPakVariant button.on')?.dataset.variant || 'pak';
390
+ const includeBasePak = variant === 'pak';
391
+ const includeBaseLicenseOnly = variant === 'license_only';
407
392
  const basePrice = includeBasePak
408
393
  ? (D.product?.basePriceRub || 5_000_000)
409
394
  : (includeBaseLicenseOnly ? (D.product?.baseLicensePriceRub || 4_000_000) : 0);
410
395
 
411
- const minSeats = (includeBasePak || includeBaseLicenseOnly) ? 0 : 1;
412
- const seats = Math.max(minSeats, Number($('#inpSeats').value) || 0);
413
- const years = Math.min(5, Math.max(1, Number($('#inpYears').value) || 1));
414
- const tier = tierById(segValue('segTier'));
415
- const disc = volumeDiscount(seats);
416
- const gross = seats * tier.pricePerUserYearRub * years;
417
- const saved = Math.round(gross * disc.discountPercent / 100);
418
- const addonsNet = gross - saved;
419
- const total = basePrice + addonsNet;
420
-
421
- $('#lblTotal').textContent = rub(total);
422
- $('#lblDiscountBadge').textContent = seats > 0 && disc.discountPercent
423
- ? `Скидка за количество −${disc.discountPercent}% · экономия ${rub(saved)}`
424
- : (includeBasePak || includeBaseLicenseOnly ? 'Включает 10 вечных мест и активацию ИИ-агентов' : 'Скидка от количества начинается с 5 мест');
425
-
426
- const breakdownRows = [];
427
- if (includeBasePak) breakdownRows.push(['Базовый комплект (с машиной)', rub(basePrice)]);
428
- if (includeBaseLicenseOnly) breakdownRows.push(['Базовая лицензия (без железа)', rub(basePrice)]);
429
- if (seats > 0) {
430
- breakdownRows.push(['Уровень поддержки доп. мест', `${esc(tier.name)} · ${esc(tier.reactionLabel)}`]);
431
- breakdownRows.push(['Доп. мест × лет', `${seats} × ${years} (${rub(addonsNet)})`]);
396
+ const minSeats = variant === 'addons_only' ? 1 : 0;
397
+ const extra = Math.max(minSeats, Number($('#inpPakExtraSeatsNum')?.value ?? $('#inpPakExtraSeats')?.value) || 0);
398
+ if ($('#inpPakExtraSeatsNum') && Number($('#inpPakExtraSeatsNum').value) !== extra) {
399
+ $('#inpPakExtraSeatsNum').value = extra;
432
400
  }
433
- breakdownRows.push(['Итого мест после покупки', `${(D.product?.baseSeats || 10) + (currentUser?.license?.addonSeats || 0) + seats}`]);
434
- $('#lblBreakdown').innerHTML = lines(breakdownRows);
435
- markLadder('ladderAddons', seats);
436
- }
437
- function syncSeats(v) {
438
- const baseMode = $('#segCabinetBase button.on')?.dataset.base || 'license_only';
439
- const minSeats = baseMode === 'none' ? 1 : 0;
440
- const n = Math.max(minSeats, Math.min(2000, Number(v) || 0));
441
- $('#inpSeats').value = n;
442
- $('#rngAddonSeats').value = Math.min(150, n);
443
- recalcAddons();
444
- }
445
- function recalcBaseOrder() {
446
- const variant = $('#segPakVariant button.on')?.dataset.variant || 'pak';
447
- const isLicenseOnly = variant === 'license_only';
448
- const basePrice = isLicenseOnly ? (D.product.baseLicensePriceRub || 4_000_000) : D.product.basePriceRub;
449
- const extra = Math.max(0, Number($('#inpPakExtraSeats').value) || 0);
401
+ const years = Math.min(5, Math.max(1, Number($('#inpPakYears')?.value) || 1));
450
402
  const tier = tierById(segValue('segPakTier'));
451
403
  const disc = volumeDiscount(extra);
452
- const gross = extra * tier.pricePerUserYearRub;
453
- const net = gross - Math.round(gross * disc.discountPercent / 100);
454
- $('#lblPakSeats').textContent = extra;
404
+ const gross = extra * tier.pricePerUserYearRub * years;
405
+ const saved = Math.round(gross * disc.discountPercent / 100);
406
+ const net = gross - saved;
407
+
455
408
  $('#lblPakTotal').textContent = rub(basePrice + net);
456
- const rows = [
457
- [isLicenseOnly ? 'Базовая лицензия (без железа)' : 'Комплекс ODANT-WORK (с машиной)', rub(basePrice)],
458
- ['Вечные рабочие места', String(D.product.baseSeats)],
459
- ['Поддержка в комплекте', '1 год · Enterprise (далее — по желанию)'],
460
- ];
461
- if (extra > 0) rows.push([`Доп. места · ${esc(tier.name)}${disc.discountPercent ? ' · −' + disc.discountPercent + '%' : ''}`, `${extra} · ${rub(net)}`]);
409
+ if ($('#lblPakDiscount')) {
410
+ $('#lblPakDiscount').textContent = extra > 0 && disc.discountPercent
411
+ ? `Скидка за количество −${disc.discountPercent}% · экономия ${rub(saved)}`
412
+ : (includeBasePak ? 'Включает сервер, 10 вечных мест и 1 год поддержки Enterprise' : (includeBaseLicenseOnly ? 'Включает 10 вечных мест и активацию ИИ-агентов' : 'Скидка от количества начинается с 5 мест'));
413
+ }
414
+ const rows = [];
415
+ if (includeBasePak) rows.push(['Комплекс ODANT-WORK (с машиной)', rub(basePrice)]);
416
+ if (includeBaseLicenseOnly) rows.push(['Базовая лицензия (без железа)', rub(basePrice)]);
417
+ if (includeBasePak || includeBaseLicenseOnly) rows.push(['Вечные рабочие места', String(D.product.baseSeats)]);
418
+ if (extra > 0) {
419
+ rows.push([`Доп. места · ${esc(tier.name)} (${years} г.)`, `${extra} мест · ${rub(net)}`]);
420
+ }
462
421
  $('#lblPakBreakdown').innerHTML = lines(rows);
422
+ markLadder('ladderPricing', extra);
463
423
  }
464
- async function submitBasePakOrder(paymentMethod = 'yookassa') {
424
+
425
+ async function buyClientConfig() {
426
+ const variant = $('#segPakVariant button.on')?.dataset.variant || 'pak';
427
+ const includeBasePak = variant === 'pak';
428
+ const includeBaseLicenseOnly = variant === 'license_only';
429
+ const minSeats = variant === 'addons_only' ? 1 : 0;
430
+ const addonSeats = Math.max(minSeats, Number($('#inpPakExtraSeatsNum')?.value ?? $('#inpPakExtraSeats')?.value) || 0);
431
+ const years = Math.min(5, Math.max(1, Number($('#inpPakYears')?.value) || 1));
432
+ const tier = segValue('segPakTier');
433
+
434
+ const intentTitle = includeBasePak
435
+ ? `Комплекс ODANT-WORK с машиной${addonSeats > 0 ? ` + ${addonSeats} доп. мест` : ''}`
436
+ : (includeBaseLicenseOnly
437
+ ? `Базовая лицензия без железа (10 вечных мест)${addonSeats > 0 ? ` + ${addonSeats} доп. мест` : ''}`
438
+ : `Дополнительные временные лицензии (${addonSeats} мест на ${years} г.)`);
439
+
465
440
  if (!isUserReadyForOrders()) {
466
- requireAuthAndOferta(() => submitBasePakOrder(paymentMethod), {
467
- companyName: $('#inpPakOrg')?.value.trim(),
468
- inn: $('#inpPakInn')?.value.trim(),
469
- email: $('#inpPakEmail')?.value.trim(),
470
- });
441
+ requireAuthAndOferta(() => buyClientConfig(), { intentTitle });
471
442
  return;
472
443
  }
473
- const variant = $('#segPakVariant button.on')?.dataset.variant || 'pak';
474
- const isLicenseOnly = variant === 'license_only';
475
444
  const st = $('#pakOrderStatus');
476
- st.textContent = paymentMethod === 'invoice' ? 'Формируем счёт-договор для юр. лица…' : 'Формируем счёт ЮKassa…';
445
+ if (st) st.textContent = 'Добавляем заказ в ваш Личный кабинет…';
477
446
  try {
478
- const data = await api('/api/orders/checkout', {
447
+ const data = await api('/api/cabinet/orders/draft', {
479
448
  sessionKey: currentUser?.sessionKey || '',
480
- includeBasePak: !isLicenseOnly,
481
- includeBaseLicenseOnly: isLicenseOnly,
482
- paymentMethod,
483
- tier: segValue('segPakTier'),
484
- addonSeats: Number($('#inpPakExtraSeats').value) || 0,
485
- years: 1,
486
- customer: { name: $('#inpPakOrg').value.trim(), inn: $('#inpPakInn').value.trim(), email: $('#inpPakEmail').value.trim() },
449
+ includeBasePak,
450
+ includeBaseLicenseOnly,
451
+ tier,
452
+ addonSeats,
453
+ years,
487
454
  });
488
- if (paymentMethod === 'invoice') {
489
- 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>`;
490
- }
491
- else {
492
- st.innerHTML = `Счёт <b>${esc(data.orderId)}</b> сформирован, лицензия <b>${esc(data.licenseId)}</b>. <a href="${esc(data.confirmationUrl)}" target="_blank" rel="noopener">Перейти к оплате →</a>`;
455
+ if (data.user) {
456
+ currentUser = { ...data.user, sessionKey: currentUser.sessionKey };
457
+ onAuthenticated();
493
458
  }
494
- refreshCatalogRegions();
459
+ if (st) st.textContent = '';
460
+ switchStage('addons');
495
461
  }
496
- catch (err) { st.textContent = err.message; }
497
- }
498
- async function submitAddonOrder(paymentMethod = 'yookassa') {
499
- if (!isUserReadyForOrders()) {
500
- requireAuthAndOferta(() => submitAddonOrder(paymentMethod));
501
- return;
462
+ catch (err) {
463
+ if (st) st.textContent = err.message;
502
464
  }
503
- const baseMode = $('#segCabinetBase button.on')?.dataset.base || 'license_only';
504
- const includeBasePak = baseMode === 'pak';
505
- const includeBaseLicenseOnly = baseMode === 'license_only';
506
- const minSeats = (includeBasePak || includeBaseLicenseOnly) ? 0 : 1;
507
- const addonSeats = Math.max(minSeats, Number($('#inpSeats').value) || 0);
465
+ }
508
466
 
509
- const st = $('#orderStatus');
510
- st.textContent = paymentMethod === 'invoice' ? 'Формируем счёт-договор для юр. лица…' : 'Формируем счёт ЮKassa…';
467
+ async function payCabinetOrder(orderId, paymentMethod = 'yookassa') {
468
+ if (!orderId) return;
469
+ let companyName = '';
470
+ let inn = '';
471
+ if (paymentMethod === 'invoice') {
472
+ companyName = $(`#invCompany-${orderId}`)?.value.trim() || currentUser?.companyName || '';
473
+ inn = $(`#invInn-${orderId}`)?.value.trim() || currentUser?.inn || '';
474
+ if (!companyName || !inn) {
475
+ const invBox = $(`#invBox-${orderId}`);
476
+ if (invBox) invBox.style.display = '';
477
+ $(`#invCompany-${orderId}`)?.focus();
478
+ return;
479
+ }
480
+ }
511
481
  try {
512
- const data = await api('/api/orders/checkout', {
482
+ const data = await api('/api/cabinet/orders/pay', {
513
483
  sessionKey: currentUser?.sessionKey || '',
514
- includeBasePak,
515
- includeBaseLicenseOnly,
484
+ orderId,
516
485
  paymentMethod,
517
- tier: segValue('segTier'),
518
- addonSeats,
519
- years: Number($('#inpYears').value) || 1,
520
- licenseId: $('#inpLicId').value.trim(),
521
- customer: { name: currentUser?.companyName || '', inn: currentUser?.inn || '', email: currentUser?.email || '' },
486
+ companyName,
487
+ inn,
522
488
  });
523
- if (paymentMethod === 'invoice') {
524
- 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>`;
489
+ if (data.user) {
490
+ currentUser = { ...data.user, sessionKey: currentUser.sessionKey };
491
+ onAuthenticated();
525
492
  }
526
- else {
527
- st.innerHTML = `Заказ <b>${esc(data.orderId)}</b> на <b>${rub(data.amountRub)}</b>. <a href="${esc(data.confirmationUrl)}" target="_blank" rel="noopener">Перейти к оплате →</a>`;
493
+ const url = data.invoiceUrl || data.confirmationUrl;
494
+ if (url) window.open(url, '_blank', 'noopener');
495
+ }
496
+ catch (err) {
497
+ alert(err.message);
498
+ }
499
+ }
500
+
501
+ async function cancelCabinetOrder(orderId) {
502
+ if (!orderId) return;
503
+ try {
504
+ const data = await api('/api/cabinet/orders/cancel', {
505
+ sessionKey: currentUser?.sessionKey || '',
506
+ orderId,
507
+ });
508
+ if (data.user) {
509
+ currentUser = { ...data.user, sessionKey: currentUser.sessionKey };
510
+ onAuthenticated();
528
511
  }
529
512
  refreshCatalogRegions();
530
513
  }
531
- catch (err) { st.textContent = err.message; }
514
+ catch (err) {
515
+ alert(err.message);
516
+ }
532
517
  }
518
+
533
519
  async function generateCabinetToken() {
534
520
  if (!currentUser?.sessionKey) return;
535
521
  try {
@@ -570,7 +556,7 @@
570
556
  ];
571
557
  let i = 0;
572
558
  const next = () => {
573
- if (currentStage !== 'agents' || i >= script.length) return;
559
+ if (currentStage !== 'product' || i >= script.length) return;
574
560
  const [who, text, tools] = script[i++];
575
561
  const el = document.createElement('div');
576
562
  if (who === 'v') {
@@ -919,32 +905,33 @@
919
905
  code = code || selectedCode;
920
906
  if (!code) return;
921
907
  selectRegion(code);
908
+ const reg = REGIONS.find(r => codeOf(r) === code);
909
+ const regTitle = reg ? nameOf(reg) : code;
922
910
  if (!isUserReadyForOrders()) {
923
911
  requireAuthAndOferta(() => reserveRegionFranchise(code), {
924
- companyName: $('#inpPartnerCompany')?.value.trim(),
925
- inn: $('#inpPartnerInn')?.value.trim(),
926
- email: $('#inpPartnerEmail')?.value.trim(),
912
+ intentTitle: `Резервирование франшизы (${regTitle})`,
927
913
  });
928
914
  return;
929
915
  }
930
916
  const msg = $('#partnerActionMsg');
931
- msg.textContent = 'Оформляем резерв и счёт на залог…';
917
+ if (msg) msg.textContent = 'Добавляем резерв франшизы в ваш Личный кабинет…';
932
918
  try {
933
- const data = await api('/api/partners/reserve', {
919
+ const data = await api('/api/cabinet/orders/draft', {
934
920
  sessionKey: currentUser?.sessionKey || '',
921
+ kind: 'franchise_reservation',
935
922
  regionCode: code,
936
- companyName: $('#inpPartnerCompany').value.trim(),
937
- inn: $('#inpPartnerInn').value.trim(),
938
- email: $('#inpPartnerEmail').value.trim(),
939
923
  });
940
- mergeRegion(data.regionQuota);
941
- paintMap();
942
- renderRegionsTable();
943
- selectRegion(code);
944
- 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>`;
924
+ if (data.user) {
925
+ currentUser = { ...data.user, sessionKey: currentUser.sessionKey };
926
+ onAuthenticated();
927
+ }
928
+ if (msg) msg.textContent = '';
945
929
  refreshCatalogRegions();
930
+ switchStage('addons');
931
+ }
932
+ catch (err) {
933
+ if (msg) msg.textContent = err.message;
946
934
  }
947
- catch (err) { msg.textContent = err.message; }
948
935
  }
949
936
  window.reserveRegionFranchise = reserveRegionFranchise;
950
937
 
@@ -1200,9 +1187,17 @@
1200
1187
  /* ================= Привязка событий ================= */
1201
1188
  function bind() {
1202
1189
  document.addEventListener('click', (e) => {
1203
- const el = e.target.closest('[data-stage],[data-action],[data-reserve],[data-bot],[data-ask],[data-district],th[data-sort],#regionsTableBody tr');
1190
+ const el = e.target.closest('[data-stage],[data-action],[data-reserve],[data-pay-order],[data-invoice-toggle],[data-cancel-order],[data-ask],[data-district],th[data-sort],#regionsTableBody tr');
1204
1191
  if (!el) return;
1205
1192
  if (el.matches('[data-reserve]')) { e.stopPropagation(); reserveRegionFranchise(el.dataset.reserve); return; }
1193
+ if (el.matches('[data-pay-order]')) { e.stopPropagation(); payCabinetOrder(el.dataset.payOrder, el.dataset.payMethod || 'yookassa'); return; }
1194
+ if (el.matches('[data-invoice-toggle]')) {
1195
+ e.stopPropagation();
1196
+ const box = $(`#invBox-${el.dataset.invoiceToggle}`);
1197
+ if (box) box.style.display = box.style.display === 'none' ? '' : 'none';
1198
+ return;
1199
+ }
1200
+ if (el.matches('[data-cancel-order]')) { e.stopPropagation(); cancelCabinetOrder(el.dataset.cancelOrder); return; }
1206
1201
  if (el.matches('#regionsTableBody tr')) { selectRegion(el.dataset.code, { zoom: true }); $('#mapBox').scrollIntoView({ block: 'nearest', behavior: 'smooth' }); return; }
1207
1202
  if (el.matches('th[data-sort]')) {
1208
1203
  SORT.dir = SORT.key === el.dataset.sort ? -SORT.dir : (el.dataset.sort === 'name' || el.dataset.sort === 'district' ? 1 : -1);
@@ -1211,14 +1206,22 @@
1211
1206
  return;
1212
1207
  }
1213
1208
  if (el.matches('[data-district]')) { setDistrict(el.dataset.district); return; }
1214
- if (el.matches('[data-bot]')) { $('#botText').value = el.dataset.bot; sendBot(); return; }
1215
1209
  if (el.matches('[data-ask]')) { sendAi(el.dataset.ask); return; }
1216
1210
  if (el.dataset.stage) { e.preventDefault(); switchStage(el.dataset.stage); return; }
1217
1211
  switch (el.dataset.action) {
1218
- case 'auth': openAuth(); break;
1212
+ case 'auth':
1213
+ if (isUserReadyForOrders() && currentStage !== 'addons') {
1214
+ switchStage('addons');
1215
+ }
1216
+ else {
1217
+ pendingAuthAction = null;
1218
+ openAuth();
1219
+ }
1220
+ break;
1219
1221
  case 'auth-close': closeAuth(); break;
1220
- case 'login': performLogin(); break;
1221
- case 'logout': performLogout(); break;
1222
+ case 'login': performLogin('email'); break;
1223
+ case 'oauth-login': performLogin(el.dataset.provider || 'email'); break;
1224
+ case 'logout': e.preventDefault(); performLogout(); break;
1222
1225
  case 'open-oferta': e.preventDefault(); openOfertaModal(); break;
1223
1226
  case 'oferta-close': closeOfertaModal(); break;
1224
1227
  case 'accept-from-oferta':
@@ -1232,20 +1235,18 @@
1232
1235
  else requireAuthAndOferta(() => switchStage('addons'));
1233
1236
  break;
1234
1237
  case 'pick-tier':
1235
- $$('#segTier button').forEach(b => b.classList.toggle('on', b.dataset.tier === el.dataset.tier));
1236
- recalcAddons();
1237
- if (isUserReadyForOrders()) switchStage('addons');
1238
- else requireAuthAndOferta(() => switchStage('addons'));
1238
+ $$('#segPakTier button').forEach(b => b.classList.toggle('on', b.dataset.tier === el.dataset.tier));
1239
+ if ($('#segPakVariant')) {
1240
+ $$('#segPakVariant button').forEach(b => b.classList.toggle('on', b.dataset.variant === 'addons_only'));
1241
+ }
1242
+ syncClientSeats(5);
1243
+ buyClientConfig();
1239
1244
  break;
1240
- case 'order-pak': submitBasePakOrder('yookassa'); break;
1241
- case 'order-pak-invoice': submitBasePakOrder('invoice'); break;
1242
- case 'order-addons': submitAddonOrder('yookassa'); break;
1243
- case 'order-addons-invoice': submitAddonOrder('invoice'); break;
1245
+ case 'buy-client-config': buyClientConfig(); break;
1244
1246
  case 'gen-hub-token': generateCabinetToken(); break;
1245
1247
  case 'copy-hub-token': copyCabinetToken(); break;
1246
1248
  case 'refresh-cabinet': refreshCabinetUser(); break;
1247
1249
  case 'reserve-selected': reserveRegionFranchise(selectedCode); break;
1248
- case 'bot-send': sendBot(); break;
1249
1250
  case 'cert-close': $('#certModal')?.classList.remove('open'); break;
1250
1251
  case 'cert-copy':
1251
1252
  navigator.clipboard?.writeText($('#certJsonBox')?.textContent || '');
@@ -1260,19 +1261,15 @@
1260
1261
  document.addEventListener('keydown', (e) => {
1261
1262
  if (e.key === 'Escape') { closeAuth(); closeOfertaModal(); $('#certModal')?.classList.remove('open'); toggleAi(false); }
1262
1263
  });
1263
- $('#authModal')?.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') performLogin(); });
1264
+ $('#authModal')?.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') performLogin('email'); });
1264
1265
  $('#aiInput')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendAi(); });
1265
- $('#botText')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendBot(); });
1266
- $('#rngAddonSeats')?.addEventListener('input', (e) => syncSeats(e.target.value));
1267
- $('#inpSeats')?.addEventListener('input', (e) => syncSeats(e.target.value));
1268
- $('#inpYears')?.addEventListener('input', recalcAddons);
1269
- $('#inpPakExtraSeats')?.addEventListener('input', recalcBaseOrder);
1266
+ $('#inpPakExtraSeats')?.addEventListener('input', (e) => syncClientSeats(e.target.value));
1267
+ $('#inpPakExtraSeatsNum')?.addEventListener('input', (e) => syncClientSeats(e.target.value));
1268
+ $('#inpPakYears')?.addEventListener('input', recalcBaseOrder);
1270
1269
  $('#rngPartnerSales')?.addEventListener('input', recalcPartner);
1271
1270
  $('#inpRegionFilter')?.addEventListener('input', renderRegionsTable);
1272
- bindSeg('segTier', recalcAddons);
1273
- bindSeg('segCabinetBase', () => { syncSeats($('#inpSeats')?.value || 0); });
1274
1271
  bindSeg('segPakTier', recalcBaseOrder);
1275
- bindSeg('segPakVariant', recalcBaseOrder);
1272
+ bindSeg('segPakVariant', () => syncClientSeats($('#inpPakExtraSeatsNum')?.value || 0));
1276
1273
  window.addEventListener('message', (e) => {
1277
1274
  if (e.data?.type === 'odant:order-paid') {
1278
1275
  refreshCabinetUser();
@@ -1286,7 +1283,6 @@
1286
1283
  applyTheme(document.documentElement.getAttribute('data-theme') || 'dark');
1287
1284
  bind();
1288
1285
  renderOrbit();
1289
- recalcAddons();
1290
1286
  recalcBaseOrder();
1291
1287
  recalcPartner();
1292
1288
  renderRegionsTable();