work-license-server 1.0.6 → 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,93 +234,129 @@
240
234
  }
241
235
  }
242
236
  catch { /* сессия устарела */ }
243
- updateOfertaBanners();
244
- updateClientSupportCard();
245
237
  }
246
238
 
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>
239
+ function onAuthenticated() {
240
+ if (!currentUser) return;
241
+ if ($('#tileAddons')) $('#tileAddons').style.display = isUserReadyForOrders() ? '' : 'none';
242
+ if ($('#btnAuthText')) $('#btnAuthText').textContent = currentUser.companyName || currentUser.email;
243
+ if ($('#inpCabinetHubToken')) $('#inpCabinetHubToken').value = currentUser.hubToken || '';
244
+ if ($('#authEmail')) $('#authEmail').value = currentUser.email || '';
245
+ if ($('#authOfertaCheck')) $('#authOfertaCheck').checked = Boolean(currentUser.ofertaAccepted);
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>`;
249
+ }
250
+ renderCabinetOrders();
251
+ renderCabinetLicenses();
252
+ moveIndicator();
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>
258
288
  </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 = `
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">
266
293
  <div>
267
- <span class="tag">Требуется регистрация и акцепт оферты</span>
268
- <div style="margin-top:6px;font-size:13.5px;color:var(--text-2)">
269
- Любые действия по заказу комплекса, докупке лицензий, оплате через ЮKassa и резервированию региональной франшизы выполняются после регистрации и подписания Публичного договора-оферты (ПЭП).
270
- </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>
271
296
  </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
- });
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('');
278
305
  }
279
306
 
280
- function updateClientSupportCard() {
281
- const box = $('#clientLicenseBox');
307
+ function renderCabinetLicenses() {
308
+ const box = $('#cabinetLicensesBox');
282
309
  if (!box) return;
283
- if (!currentUser || !currentUser.license) {
284
- box.textContent = 'Войдите в личный кабинет и подпишите договор-оферту, чтобы получить код привязки вашего комплекса к ботам MAX, Telegram и VK.';
310
+ const lic = currentUser?.license;
311
+ const files = lic?.licenseFiles || [];
312
+ const reservations = (currentUser?.reservations || []).filter(r => r.depositPaid);
313
+ if (!files.length && !reservations.length) {
314
+ box.innerHTML = `<div style="padding:12px 14px;border-radius:10px;border:1px dashed var(--border);color:var(--muted);">
315
+ Активных оплаченных лицензий пока нет. После оплаты заказа выше подписанные файлы лицензий (<code>.lic.json</code>) сразу появятся здесь и автоматически загрузятся в ваш локальный WORK по токену владельца.
316
+ </div>`;
285
317
  return;
286
318
  }
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');
319
+ const rows = files.map(f => {
320
+ const lf = f.licenseFile || f;
321
+ const isBase = lf.kind === 'base' || lf.perpetual;
322
+ const expText = isBase ? 'Бессрочно (постоянная)' : (lf.expiresAt ? 'до ' + new Date(lf.expiresAt).toLocaleDateString('ru-RU') + ` (${lf.years || 1} г.)` : '—');
323
+ const title = isBase ? 'Первичная постоянная лицензия (10 вечных мест + ИИ-агенты)' : `Временная лицензия (+${lf.seats} конк. мест · ${String(lf.tier || '').toUpperCase()})`;
324
+ return `<div style="display:flex;justify-content:space-between;gap:12px;padding:8px 0;border-bottom:1px solid var(--border);align-items:center;">
325
+ <div><b>${esc(title)}</b><br><code style="font-size:11.5px;color:var(--muted)">${esc(f.fileName || lf.fileId)}</code></div>
326
+ <div style="text-align:right"><span class="pill" style="padding:3px 10px;font-size:11.5px">${esc(expText)}</span></div>
327
+ </div>`;
328
+ }).join('');
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;
299
334
  }
300
335
 
301
- function onAuthenticated() {
302
- if (!currentUser) return;
303
- $('#tileAddons').style.display = isUserReadyForOrders() ? '' : 'none';
304
- $('#btnAuthText').textContent = currentUser.companyName || currentUser.email;
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;
336
+ async function refreshCabinetUser() {
337
+ if (!currentUser?.sessionKey) return;
338
+ try {
339
+ const data = await api('/api/auth/me?sessionKey=' + encodeURIComponent(currentUser.sessionKey));
340
+ if (data.authenticated && data.user) {
341
+ currentUser = { ...data.user, sessionKey: currentUser.sessionKey };
342
+ onAuthenticated();
343
+ }
344
+ }
345
+ catch { /* ignore */ }
346
+ }
347
+
348
+ function copyCabinetToken() {
349
+ const val = $('#inpCabinetHubToken')?.value || '';
350
+ if (!val) return;
351
+ navigator.clipboard?.writeText(val);
352
+ const st = $('#tokenCopyStatus');
353
+ if (st) {
354
+ st.textContent = 'Токен скопирован в буфер обмена! Вставьте его в локальном WORK → Лицензия.';
355
+ setTimeout(() => { if (st.textContent.includes('скопирован')) st.textContent = ''; }, 5000);
321
356
  }
322
- $('#userBadgeBox').innerHTML = `${esc(currentUser.companyName)} (ИНН ${esc(currentUser.inn || '—')}) · лицензия <b>${esc(currentUser.licenseId)}</b> · ПЭП <code>${esc(currentUser.ofertaSignatureId || '—')}</code>`;
323
- updateOfertaBanners();
324
- updateClientSupportCard();
325
- moveIndicator();
326
- recalcAddons();
327
357
  }
328
358
 
329
- /* ================= Калькуляторы ================= */
359
+ /* ================= Калькуляторы раздела «Клиентам» ================= */
330
360
  function volumeDiscount(seats) {
331
361
  const s = Math.max(0, Math.floor(Number(seats) || 0));
332
362
  return (D.discounts || []).find(d => s >= d.minSeats) || { discountPercent: 0, minSeats: 1 };
@@ -345,114 +375,147 @@
345
375
  const tierById = (id) => (D.tiers || []).find(t => t.id === id) || D.tiers[1];
346
376
  const lines = (rows) => rows.map(([k, v]) => `<div><span>${k}</span><b>${v}</b></div>`).join('');
347
377
 
348
- function recalcAddons() {
349
- const seats = Math.max(1, Number($('#inpSeats').value) || 1);
350
- const years = Math.min(5, Math.max(1, Number($('#inpYears').value) || 1));
351
- const tier = tierById(segValue('segTier'));
352
- const disc = volumeDiscount(seats);
353
- const gross = seats * tier.pricePerUserYearRub * years;
354
- const saved = Math.round(gross * disc.discountPercent / 100);
355
- $('#lblTotal').textContent = rub(gross - saved);
356
- $('#lblDiscountBadge').textContent = disc.discountPercent
357
- ? `Скидка за количество −${disc.discountPercent}% · экономия ${rub(saved)}`
358
- : 'Скидка от количества начинается с 5 мест';
359
- $('#lblBreakdown').innerHTML = lines([
360
- ['Уровень поддержки', `${esc(tier.name)} · ${esc(tier.reactionLabel)}`],
361
- ['Цена места в год', rub(tier.pricePerUserYearRub)],
362
- ['Мест × лет', `${seats} × ${years}`],
363
- ['Без скидки', rub(gross)],
364
- ['Скидка', `−${disc.discountPercent}%`],
365
- ['Всего мест после докупки', `${(D.product?.baseSeats || 10) + seats}`],
366
- ]);
367
- markLadder('ladderAddons', seats);
368
- }
369
- function syncSeats(v) {
370
- const n = Math.max(1, Math.min(2000, Number(v) || 1));
371
- $('#inpSeats').value = n;
372
- $('#rngAddonSeats').value = Math.min(150, n);
373
- recalcAddons();
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();
374
385
  }
386
+
375
387
  function recalcBaseOrder() {
388
+ if (!$('#lblPakTotal')) return;
376
389
  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;
379
- const extra = Math.max(0, Number($('#inpPakExtraSeats').value) || 0);
390
+ const includeBasePak = variant === 'pak';
391
+ const includeBaseLicenseOnly = variant === 'license_only';
392
+ const basePrice = includeBasePak
393
+ ? (D.product?.basePriceRub || 5_000_000)
394
+ : (includeBaseLicenseOnly ? (D.product?.baseLicensePriceRub || 4_000_000) : 0);
395
+
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;
400
+ }
401
+ const years = Math.min(5, Math.max(1, Number($('#inpPakYears')?.value) || 1));
380
402
  const tier = tierById(segValue('segPakTier'));
381
403
  const disc = volumeDiscount(extra);
382
- const gross = extra * tier.pricePerUserYearRub;
383
- const net = gross - Math.round(gross * disc.discountPercent / 100);
384
- $('#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
+
385
408
  $('#lblPakTotal').textContent = rub(basePrice + net);
386
- const rows = [
387
- [isLicenseOnly ? 'Базовая лицензия (без железа)' : 'Комплекс ODANT-WORK (с машиной)', rub(basePrice)],
388
- ['Вечные рабочие места', String(D.product.baseSeats)],
389
- ['Поддержка в комплекте', '1 год · Enterprise (далее — по желанию)'],
390
- ];
391
- 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
+ }
392
421
  $('#lblPakBreakdown').innerHTML = lines(rows);
422
+ markLadder('ladderPricing', extra);
393
423
  }
394
- 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
+
395
440
  if (!isUserReadyForOrders()) {
396
- requireAuthAndOferta(() => submitBasePakOrder(paymentMethod), {
397
- companyName: $('#inpPakOrg')?.value.trim(),
398
- inn: $('#inpPakInn')?.value.trim(),
399
- email: $('#inpPakEmail')?.value.trim(),
400
- });
441
+ requireAuthAndOferta(() => buyClientConfig(), { intentTitle });
401
442
  return;
402
443
  }
403
- const variant = $('#segPakVariant button.on')?.dataset.variant || 'pak';
404
- const isLicenseOnly = variant === 'license_only';
405
444
  const st = $('#pakOrderStatus');
406
- st.textContent = paymentMethod === 'invoice' ? 'Формируем счёт-договор для юр. лица…' : 'Формируем счёт ЮKassa…';
445
+ if (st) st.textContent = 'Добавляем заказ в ваш Личный кабинет…';
407
446
  try {
408
- const data = await api('/api/orders/checkout', {
447
+ const data = await api('/api/cabinet/orders/draft', {
409
448
  sessionKey: currentUser?.sessionKey || '',
410
- includeBasePak: !isLicenseOnly,
411
- includeBaseLicenseOnly: isLicenseOnly,
412
- paymentMethod,
413
- tier: segValue('segPakTier'),
414
- addonSeats: Number($('#inpPakExtraSeats').value) || 0,
415
- years: 1,
416
- customer: { name: $('#inpPakOrg').value.trim(), inn: $('#inpPakInn').value.trim(), email: $('#inpPakEmail').value.trim() },
449
+ includeBasePak,
450
+ includeBaseLicenseOnly,
451
+ tier,
452
+ addonSeats,
453
+ years,
417
454
  });
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>`;
455
+ if (data.user) {
456
+ currentUser = { ...data.user, sessionKey: currentUser.sessionKey };
457
+ onAuthenticated();
423
458
  }
424
- refreshCatalogRegions();
459
+ if (st) st.textContent = '';
460
+ switchStage('addons');
461
+ }
462
+ catch (err) {
463
+ if (st) st.textContent = err.message;
425
464
  }
426
- catch (err) { st.textContent = err.message; }
427
465
  }
428
- async function submitAddonOrder(paymentMethod = 'yookassa') {
429
- if (!isUserReadyForOrders()) {
430
- requireAuthAndOferta(() => submitAddonOrder(paymentMethod));
431
- return;
466
+
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
+ }
432
480
  }
433
- const st = $('#orderStatus');
434
- st.textContent = paymentMethod === 'invoice' ? 'Формируем счёт-договор для юр. лица…' : 'Формируем счёт ЮKassa…';
435
481
  try {
436
- const data = await api('/api/orders/checkout', {
482
+ const data = await api('/api/cabinet/orders/pay', {
437
483
  sessionKey: currentUser?.sessionKey || '',
438
- includeBasePak: false,
484
+ orderId,
439
485
  paymentMethod,
440
- tier: segValue('segTier'),
441
- addonSeats: Number($('#inpSeats').value) || 1,
442
- years: Number($('#inpYears').value) || 1,
443
- licenseId: $('#inpLicId').value.trim(),
444
- customer: { name: currentUser?.companyName || '', inn: currentUser?.inn || '', email: currentUser?.email || '' },
486
+ companyName,
487
+ inn,
445
488
  });
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>`;
489
+ if (data.user) {
490
+ currentUser = { ...data.user, sessionKey: currentUser.sessionKey };
491
+ onAuthenticated();
448
492
  }
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>`;
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();
451
511
  }
452
512
  refreshCatalogRegions();
453
513
  }
454
- catch (err) { st.textContent = err.message; }
514
+ catch (err) {
515
+ alert(err.message);
516
+ }
455
517
  }
518
+
456
519
  async function generateCabinetToken() {
457
520
  if (!currentUser?.sessionKey) return;
458
521
  try {
@@ -493,7 +556,7 @@
493
556
  ];
494
557
  let i = 0;
495
558
  const next = () => {
496
- if (currentStage !== 'agents' || i >= script.length) return;
559
+ if (currentStage !== 'product' || i >= script.length) return;
497
560
  const [who, text, tools] = script[i++];
498
561
  const el = document.createElement('div');
499
562
  if (who === 'v') {
@@ -842,32 +905,33 @@
842
905
  code = code || selectedCode;
843
906
  if (!code) return;
844
907
  selectRegion(code);
908
+ const reg = REGIONS.find(r => codeOf(r) === code);
909
+ const regTitle = reg ? nameOf(reg) : code;
845
910
  if (!isUserReadyForOrders()) {
846
911
  requireAuthAndOferta(() => reserveRegionFranchise(code), {
847
- companyName: $('#inpPartnerCompany')?.value.trim(),
848
- inn: $('#inpPartnerInn')?.value.trim(),
849
- email: $('#inpPartnerEmail')?.value.trim(),
912
+ intentTitle: `Резервирование франшизы (${regTitle})`,
850
913
  });
851
914
  return;
852
915
  }
853
916
  const msg = $('#partnerActionMsg');
854
- msg.textContent = 'Оформляем резерв и счёт на залог…';
917
+ if (msg) msg.textContent = 'Добавляем резерв франшизы в ваш Личный кабинет…';
855
918
  try {
856
- const data = await api('/api/partners/reserve', {
919
+ const data = await api('/api/cabinet/orders/draft', {
857
920
  sessionKey: currentUser?.sessionKey || '',
921
+ kind: 'franchise_reservation',
858
922
  regionCode: code,
859
- companyName: $('#inpPartnerCompany').value.trim(),
860
- inn: $('#inpPartnerInn').value.trim(),
861
- email: $('#inpPartnerEmail').value.trim(),
862
923
  });
863
- mergeRegion(data.regionQuota);
864
- paintMap();
865
- renderRegionsTable();
866
- selectRegion(code);
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>`;
924
+ if (data.user) {
925
+ currentUser = { ...data.user, sessionKey: currentUser.sessionKey };
926
+ onAuthenticated();
927
+ }
928
+ if (msg) msg.textContent = '';
868
929
  refreshCatalogRegions();
930
+ switchStage('addons');
931
+ }
932
+ catch (err) {
933
+ if (msg) msg.textContent = err.message;
869
934
  }
870
- catch (err) { msg.textContent = err.message; }
871
935
  }
872
936
  window.reserveRegionFranchise = reserveRegionFranchise;
873
937
 
@@ -1123,9 +1187,17 @@
1123
1187
  /* ================= Привязка событий ================= */
1124
1188
  function bind() {
1125
1189
  document.addEventListener('click', (e) => {
1126
- 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');
1127
1191
  if (!el) return;
1128
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; }
1129
1201
  if (el.matches('#regionsTableBody tr')) { selectRegion(el.dataset.code, { zoom: true }); $('#mapBox').scrollIntoView({ block: 'nearest', behavior: 'smooth' }); return; }
1130
1202
  if (el.matches('th[data-sort]')) {
1131
1203
  SORT.dir = SORT.key === el.dataset.sort ? -SORT.dir : (el.dataset.sort === 'name' || el.dataset.sort === 'district' ? 1 : -1);
@@ -1134,14 +1206,22 @@
1134
1206
  return;
1135
1207
  }
1136
1208
  if (el.matches('[data-district]')) { setDistrict(el.dataset.district); return; }
1137
- if (el.matches('[data-bot]')) { $('#botText').value = el.dataset.bot; sendBot(); return; }
1138
1209
  if (el.matches('[data-ask]')) { sendAi(el.dataset.ask); return; }
1139
1210
  if (el.dataset.stage) { e.preventDefault(); switchStage(el.dataset.stage); return; }
1140
1211
  switch (el.dataset.action) {
1141
- 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;
1142
1221
  case 'auth-close': closeAuth(); break;
1143
- case 'login': performLogin(); break;
1144
- 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;
1145
1225
  case 'open-oferta': e.preventDefault(); openOfertaModal(); break;
1146
1226
  case 'oferta-close': closeOfertaModal(); break;
1147
1227
  case 'accept-from-oferta':
@@ -1155,18 +1235,18 @@
1155
1235
  else requireAuthAndOferta(() => switchStage('addons'));
1156
1236
  break;
1157
1237
  case 'pick-tier':
1158
- $$('#segTier button').forEach(b => b.classList.toggle('on', b.dataset.tier === el.dataset.tier));
1159
- recalcAddons();
1160
- if (isUserReadyForOrders()) switchStage('addons');
1161
- 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();
1162
1244
  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;
1245
+ case 'buy-client-config': buyClientConfig(); break;
1167
1246
  case 'gen-hub-token': generateCabinetToken(); break;
1247
+ case 'copy-hub-token': copyCabinetToken(); break;
1248
+ case 'refresh-cabinet': refreshCabinetUser(); break;
1168
1249
  case 'reserve-selected': reserveRegionFranchise(selectedCode); break;
1169
- case 'bot-send': sendBot(); break;
1170
1250
  case 'cert-close': $('#certModal')?.classList.remove('open'); break;
1171
1251
  case 'cert-copy':
1172
1252
  navigator.clipboard?.writeText($('#certJsonBox')?.textContent || '');
@@ -1181,18 +1261,20 @@
1181
1261
  document.addEventListener('keydown', (e) => {
1182
1262
  if (e.key === 'Escape') { closeAuth(); closeOfertaModal(); $('#certModal')?.classList.remove('open'); toggleAi(false); }
1183
1263
  });
1184
- $('#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'); });
1185
1265
  $('#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);
1266
+ $('#inpPakExtraSeats')?.addEventListener('input', (e) => syncClientSeats(e.target.value));
1267
+ $('#inpPakExtraSeatsNum')?.addEventListener('input', (e) => syncClientSeats(e.target.value));
1268
+ $('#inpPakYears')?.addEventListener('input', recalcBaseOrder);
1191
1269
  $('#rngPartnerSales')?.addEventListener('input', recalcPartner);
1192
1270
  $('#inpRegionFilter')?.addEventListener('input', renderRegionsTable);
1193
- bindSeg('segTier', recalcAddons);
1194
1271
  bindSeg('segPakTier', recalcBaseOrder);
1195
- bindSeg('segPakVariant', recalcBaseOrder);
1272
+ bindSeg('segPakVariant', () => syncClientSeats($('#inpPakExtraSeatsNum')?.value || 0));
1273
+ window.addEventListener('message', (e) => {
1274
+ if (e.data?.type === 'odant:order-paid') {
1275
+ refreshCabinetUser();
1276
+ }
1277
+ });
1196
1278
  window.addEventListener('resize', moveIndicator);
1197
1279
  window.addEventListener('hashchange', () => { const id = location.hash.slice(1); if (id && id !== currentStage) switchStage(id, { scroll: false }); });
1198
1280
  }
@@ -1201,7 +1283,6 @@
1201
1283
  applyTheme(document.documentElement.getAttribute('data-theme') || 'dark');
1202
1284
  bind();
1203
1285
  renderOrbit();
1204
- recalcAddons();
1205
1286
  recalcBaseOrder();
1206
1287
  recalcPartner();
1207
1288
  renderRegionsTable();