work-license-server 1.0.20 → 1.0.22

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/admin.js CHANGED
@@ -311,6 +311,9 @@
311
311
  if ($('#navAdmOrders')) $('#navAdmOrders').textContent = `Заказы и счета (${orders.length})`;
312
312
  if ($('#navAdmUsers')) $('#navAdmUsers').textContent = `Клиенты и администраторы (${users.length})`;
313
313
  if ($('#navAdmTickets')) $('#navAdmTickets').textContent = `Тикеты поддержки (${openTickets.length})`;
314
+ if ($('#admDataDirLabel') && data.dataDir) {
315
+ $('#admDataDirLabel').textContent = `Папка БД: ${data.dataDir}`;
316
+ }
314
317
 
315
318
  renderServersTable(licenses);
316
319
  renderFranchisesTable(franchises);
@@ -870,6 +873,57 @@
870
873
  URL.revokeObjectURL(url);
871
874
  }
872
875
 
876
+ async function exportAdminBackup() {
877
+ try {
878
+ const bundle = await api('/api/admin/backup/export');
879
+ const json = JSON.stringify(bundle, null, 2);
880
+ const blob = new Blob([json], { type: 'application/json;charset=utf-8' });
881
+ const url = URL.createObjectURL(blob);
882
+ const a = document.createElement('a');
883
+ const dayStr = new Date().toISOString().slice(0, 10);
884
+ a.href = url;
885
+ a.download = `odant-work-license-backup-${dayStr}.json`;
886
+ document.body.appendChild(a);
887
+ a.click();
888
+ a.remove();
889
+ URL.revokeObjectURL(url);
890
+ }
891
+ catch (err) {
892
+ alert(err.message);
893
+ }
894
+ }
895
+
896
+ async function importAdminBackupFile(file) {
897
+ if (!file) return;
898
+ try {
899
+ const text = await file.text();
900
+ const parsed = JSON.parse(text);
901
+ const res = await api('/api/admin/backup/import', {
902
+ backup: parsed,
903
+ mode: 'merge',
904
+ });
905
+ await refreshRegistry();
906
+ const c = res.counts || {};
907
+ alert(`Резервная копия успешно объединена с базой!\nПользователей: ${c.users || 0}, серверов/лицензий: ${c.licenses || 0}, заказов: ${c.orders || 0}, франшиз: ${c.reservations || 0}.`);
908
+ }
909
+ catch (err) {
910
+ alert('Ошибка импорта резервной копии: ' + (err.message || err));
911
+ }
912
+ }
913
+
914
+ async function recoverAdminFromNpx() {
915
+ try {
916
+ const res = await api('/api/admin/backup/recover', {});
917
+ await refreshRegistry();
918
+ const c = res.counts || {};
919
+ const srcCount = Array.isArray(res.mergedSources) ? res.mergedSources.length : 0;
920
+ alert(`Поиск по кэшу npx и системным папкам завершён (найдено источников: ${srcCount}).\nВ базе сейчас — пользователей: ${c.users || 0}, серверов/лицензий: ${c.licenses || 0}, заказов: ${c.orders || 0}, франшиз: ${c.reservations || 0}.`);
921
+ }
922
+ catch (err) {
923
+ alert(err.message);
924
+ }
925
+ }
926
+
873
927
  /* ================= Привязка событий ================= */
874
928
  function bind() {
875
929
  document.addEventListener('click', (e) => {
@@ -944,9 +998,18 @@
944
998
  navigator.clipboard?.writeText($('#certJsonBox')?.textContent || '');
945
999
  break;
946
1000
  case 'cert-download': downloadCurrentCert(); break;
1001
+ case 'admin-export-backup': exportAdminBackup(); break;
1002
+ case 'admin-import-backup': $('#inpAdmBackupFile')?.click(); break;
1003
+ case 'admin-recover-npx': recoverAdminFromNpx(); break;
947
1004
  }
948
1005
  });
949
1006
 
1007
+ $('#inpAdmBackupFile')?.addEventListener('change', (e) => {
1008
+ const file = e.target.files?.[0];
1009
+ if (file) importAdminBackupFile(file);
1010
+ e.target.value = '';
1011
+ });
1012
+
950
1013
  $('#btnTheme')?.addEventListener('click', toggleTheme);
951
1014
  $('#adminLoginScreen')?.addEventListener('keydown', (e) => {
952
1015
  if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') {
@@ -843,6 +843,89 @@ html[data-theme="dark"] .sec-nav-item.active {
843
843
  .modal-head { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
844
844
  .modal-head img { width: 46px; height: 46px; }
845
845
 
846
+ /* ---------- Интерактивный плеер презентации ---------- */
847
+ .pres-modal { padding: 14px; background: rgba(2, 5, 12, .82); }
848
+ .pres-modal .pres-card {
849
+ max-width: 1320px;
850
+ width: min(1320px, 96vw);
851
+ max-height: 94vh;
852
+ padding: 14px 18px;
853
+ display: flex;
854
+ flex-direction: column;
855
+ gap: 12px;
856
+ overflow: hidden;
857
+ }
858
+ .pres-top {
859
+ display: flex;
860
+ align-items: center;
861
+ justify-content: space-between;
862
+ gap: 12px;
863
+ flex-wrap: wrap;
864
+ }
865
+ .pres-title-wrap {
866
+ display: flex;
867
+ align-items: center;
868
+ gap: 10px;
869
+ }
870
+ .pres-title-wrap img { width: 34px; height: 34px; }
871
+ .pres-title-wrap b { display: block; font-size: 15px; line-height: 1.2; }
872
+ .pres-title-wrap span { display: block; font-size: 12px; }
873
+ .pres-sections { display: flex; gap: 6px; flex-wrap: wrap; }
874
+ .pres-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
875
+ .pres-stage {
876
+ width: 100%;
877
+ aspect-ratio: 297 / 167;
878
+ max-height: calc(94vh - 140px);
879
+ border-radius: 12px;
880
+ overflow: hidden;
881
+ border: 1px solid var(--line-strong);
882
+ background: #060d18;
883
+ position: relative;
884
+ }
885
+ .pres-stage iframe {
886
+ width: 100%;
887
+ height: 100%;
888
+ border: 0;
889
+ display: block;
890
+ }
891
+ .pres-bottom {
892
+ display: flex;
893
+ align-items: center;
894
+ justify-content: space-between;
895
+ gap: 12px;
896
+ flex-wrap: wrap;
897
+ }
898
+ .pres-counter {
899
+ font-size: 13px;
900
+ font-weight: 700;
901
+ min-width: 92px;
902
+ text-align: center;
903
+ }
904
+ .pres-dots {
905
+ display: flex;
906
+ align-items: center;
907
+ gap: 5px;
908
+ flex-wrap: wrap;
909
+ }
910
+ .pres-dot {
911
+ width: 28px;
912
+ height: 28px;
913
+ border-radius: 8px;
914
+ border: 1px solid var(--line-strong);
915
+ background: color-mix(in srgb, var(--panel-solid) 60%, transparent);
916
+ color: var(--text-2);
917
+ font-size: 12px;
918
+ font-weight: 700;
919
+ cursor: pointer;
920
+ transition: all .15s;
921
+ }
922
+ .pres-dot:hover { border-color: var(--brand); color: var(--text); }
923
+ .pres-dot.on {
924
+ background: linear-gradient(135deg, var(--brand), #2563eb);
925
+ border-color: var(--brand);
926
+ color: #fff;
927
+ }
928
+
846
929
  /* ---------- Подвал ---------- */
847
930
  .footer { margin-top: 70px; padding-top: 34px; border-top: 1px solid var(--line); display: grid; grid-template-columns: 1.3fr 1fr 1fr; gap: 30px; font-size: 13px; color: var(--muted); }
848
931
  .footer h4 { margin: 0 0 10px; color: var(--text); font-size: 14px; }
package/assets/landing.js CHANGED
@@ -2100,6 +2100,68 @@
2100
2100
  else requestAnimationFrame(frame);
2101
2101
  }
2102
2102
 
2103
+ /* ================= Интерактивный плеер презентации ODANT-WORK ================= */
2104
+ let currentPresSlide = 1;
2105
+ const PRES_TOTAL_SLIDES = 12;
2106
+
2107
+ function syncPresIndicators(slideNum) {
2108
+ currentPresSlide = Math.max(1, Math.min(PRES_TOTAL_SLIDES, Number(slideNum) || 1));
2109
+ const cnt = $('#presCounter');
2110
+ if (cnt) cnt.textContent = `Слайд ${currentPresSlide} / ${PRES_TOTAL_SLIDES}`;
2111
+ $$('#presDots .pres-dot').forEach(btn => {
2112
+ btn.classList.toggle('on', Number(btn.dataset.slide) === currentPresSlide);
2113
+ });
2114
+ const sec = currentPresSlide >= 10 ? 'franchise' : (currentPresSlide >= 8 ? 'clients' : 'product');
2115
+ $$('#presSectionTabs .chip').forEach(btn => {
2116
+ btn.classList.toggle('on', btn.dataset.sec === sec);
2117
+ });
2118
+ }
2119
+
2120
+ function openPresentationModal(startSlide = 1) {
2121
+ const modal = $('#presentationModal');
2122
+ const frame = $('#presFrame');
2123
+ if (!modal || !frame) return;
2124
+ const n = Math.max(1, Math.min(PRES_TOTAL_SLIDES, Number(startSlide) || 1));
2125
+ syncPresIndicators(n);
2126
+ const targetSrc = `/presentation?embed=1&slide=${n}`;
2127
+ if (!frame.getAttribute('src')) {
2128
+ frame.setAttribute('src', targetSrc);
2129
+ } else {
2130
+ try {
2131
+ frame.contentWindow?.postMessage({ type: 'odant-presentation-goto', slide: n }, '*');
2132
+ } catch {
2133
+ frame.setAttribute('src', targetSrc);
2134
+ }
2135
+ }
2136
+ modal.classList.add('open');
2137
+ }
2138
+
2139
+ function closePresentationModal() {
2140
+ $('#presentationModal')?.classList.remove('open');
2141
+ }
2142
+
2143
+ function gotoPresentationSlide(slideNum) {
2144
+ const n = Math.max(1, Math.min(PRES_TOTAL_SLIDES, Number(slideNum) || 1));
2145
+ syncPresIndicators(n);
2146
+ const frame = $('#presFrame');
2147
+ try {
2148
+ frame?.contentWindow?.postMessage({ type: 'odant-presentation-goto', slide: n }, '*');
2149
+ } catch { /* ignore */ }
2150
+ }
2151
+
2152
+ function stepPresentationSlide(delta) {
2153
+ gotoPresentationSlide(currentPresSlide + (Number(delta) || 0));
2154
+ }
2155
+
2156
+ window.addEventListener('message', (e) => {
2157
+ const d = e.data || {};
2158
+ if (d.type === 'odant-presentation-slide' && d.slide) {
2159
+ syncPresIndicators(d.slide);
2160
+ } else if (d.type === 'odant-presentation-close') {
2161
+ closePresentationModal();
2162
+ }
2163
+ });
2164
+
2103
2165
  /* ================= Привязка событий ================= */
2104
2166
  function bind() {
2105
2167
  document.addEventListener('click', (e) => {
@@ -2223,6 +2285,26 @@
2223
2285
  case 'cert-copy':
2224
2286
  navigator.clipboard?.writeText($('#certJsonBox')?.textContent || '');
2225
2287
  break;
2288
+ case 'open-presentation':
2289
+ e.preventDefault();
2290
+ openPresentationModal(Number(el.dataset.slide || 1));
2291
+ break;
2292
+ case 'pres-close':
2293
+ closePresentationModal();
2294
+ break;
2295
+ case 'pres-goto':
2296
+ gotoPresentationSlide(Number(el.dataset.slide || 1));
2297
+ break;
2298
+ case 'pres-prev':
2299
+ stepPresentationSlide(-1);
2300
+ break;
2301
+ case 'pres-next':
2302
+ stepPresentationSlide(1);
2303
+ break;
2304
+ case 'pres-jump-stage':
2305
+ closePresentationModal();
2306
+ if (el.dataset.targetStage) switchStage(el.dataset.targetStage);
2307
+ break;
2226
2308
  case 'ai-toggle': toggleAi(); break;
2227
2309
  case 'ai-send': sendAi(); break;
2228
2310
  }
@@ -2230,8 +2312,13 @@
2230
2312
  $('#authModal')?.addEventListener('click', (e) => { if (e.target.id === 'authModal') closeAuth(); });
2231
2313
  $('#ofertaModal')?.addEventListener('click', (e) => { if (e.target.id === 'ofertaModal') closeOfertaModal(); });
2232
2314
  $('#certModal')?.addEventListener('click', (e) => { if (e.target.id === 'certModal') $('#certModal').classList.remove('open'); });
2315
+ $('#presentationModal')?.addEventListener('click', (e) => { if (e.target.id === 'presentationModal') closePresentationModal(); });
2233
2316
  document.addEventListener('keydown', (e) => {
2234
- if (e.key === 'Escape') { closeAuth(); closeOfertaModal(); $('#certModal')?.classList.remove('open'); toggleAi(false); }
2317
+ if (e.key === 'Escape') { closeAuth(); closeOfertaModal(); closePresentationModal(); $('#certModal')?.classList.remove('open'); toggleAi(false); }
2318
+ else if ($('#presentationModal')?.classList.contains('open')) {
2319
+ if (e.key === 'ArrowRight' || e.key === 'PageDown') { e.preventDefault(); stepPresentationSlide(1); }
2320
+ else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); stepPresentationSlide(-1); }
2321
+ }
2235
2322
  });
2236
2323
  $('#authModal')?.addEventListener('keydown', (e) => {
2237
2324
  if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') {
package/bin/server.mjs CHANGED
@@ -2,16 +2,75 @@
2
2
  /**
3
3
  * Точка запуска лицензионного сервера и лендинга ПАК WORK (work.odant.org).
4
4
  */
5
- import { fileURLToPath } from 'node:url';
5
+ import fs from 'node:fs';
6
+ import net from 'node:net';
7
+ import path from 'node:path';
8
+ import * as childProcess from 'node:child_process';
6
9
  import { createLicenseServerApp } from '../src/server.js';
10
+ import { resolvePersistentDataDir } from '../src/store.js';
7
11
 
8
12
  const port = Number(process.env.PORT || process.env.WORK_LICENSE_PORT || 8020);
9
- const defaultDataDir = fileURLToPath(new URL('../data', import.meta.url));
10
- const dataDir = process.env.WORK_LICENSE_DATA_DIR || defaultDataDir;
13
+ const dataDir = resolvePersistentDataDir();
11
14
 
12
- const app = createLicenseServerApp({ dataDir });
15
+ async function releasePreviousInstance() {
16
+ const pidFile = path.join(dataDir, 'run.pid');
17
+ try {
18
+ if (fs.existsSync(pidFile)) {
19
+ const oldPid = Number(fs.readFileSync(pidFile, 'utf8').trim());
20
+ if (Number.isInteger(oldPid) && oldPid > 0 && oldPid !== process.pid) {
21
+ try {
22
+ process.kill(oldPid);
23
+ for (let i = 0; i < 15; i++) {
24
+ await new Promise(r => setTimeout(r, 100));
25
+ try { process.kill(oldPid, 0); }
26
+ catch { break; }
27
+ }
28
+ }
29
+ catch { /* процесс уже завершён */ }
30
+ }
31
+ }
32
+ }
33
+ catch { /* ignore */ }
34
+
35
+ const isPortBusy = () => new Promise((resolve) => {
36
+ const sock = net.createConnection({ host: '127.0.0.1', port });
37
+ sock.once('connect', () => { sock.destroy(); resolve(true); });
38
+ sock.once('error', () => { sock.destroy(); resolve(false); });
39
+ });
40
+
41
+ if (await isPortBusy() && process.platform === 'win32') {
42
+ try {
43
+ const out = childProcess.execSync(`netstat -ano | findstr :${port}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
44
+ for (const line of out.split(/\r?\n/)) {
45
+ const m = line.trim().match(/LISTENING\s+(\d+)$/i);
46
+ const pid = m ? Number(m[1]) : 0;
47
+ if (pid && pid !== process.pid) {
48
+ try { process.kill(pid); } catch { /* ignore */ }
49
+ }
50
+ }
51
+ for (let i = 0; i < 15; i++) {
52
+ await new Promise(r => setTimeout(r, 100));
53
+ if (!(await isPortBusy()))
54
+ break;
55
+ }
56
+ }
57
+ catch { /* ignore */ }
58
+ }
59
+
60
+ try {
61
+ fs.mkdirSync(dataDir, { recursive: true });
62
+ fs.writeFileSync(pidFile, String(process.pid), 'utf8');
63
+ }
64
+ catch { /* ignore */ }
65
+ }
66
+
67
+ await releasePreviousInstance();
68
+
69
+ const app = createLicenseServerApp({ dataDir, autoRecover: true });
13
70
  const server = await app.listen(port);
14
71
  const addr = server.address();
15
72
  const actualPort = typeof addr === 'object' && addr ? addr.port : port;
73
+ const counts = app.store.getStoreCounts();
16
74
  console.log(`[work.odant.org] Витрина ODANT-WORK: http://localhost:${actualPort}/`);
17
75
  console.log(`[work.odant.org] Панель администратора: http://localhost:${actualPort}/admin`);
76
+ console.log(`[work.odant.org] Постоянная база данных: ${app.store.stateFile} (пользователей: ${counts.users}, серверов/лицензий: ${counts.licenses}, заказов: ${counts.orders})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "work-license-server",
3
- "version": "1.0.20",
3
+ "version": "1.0.22",
4
4
  "description": "Лицензионный сервер, биллинг ЮKassa, боты поддержки (MAX, Telegram, VK) и лендинг ПАК WORK (work.odant.org)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/admin.js CHANGED
@@ -129,6 +129,15 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
129
129
  <button type="button" class="sec-nav-item" data-adm-tab="orders" id="navAdmOrders">Заказы и счета (0)</button>
130
130
  <button type="button" class="sec-nav-item" data-adm-tab="users" id="navAdmUsers">Клиенты и администраторы (0)</button>
131
131
  <button type="button" class="sec-nav-item" data-adm-tab="tickets" id="navAdmTickets">Тикеты поддержки (0)</button>
132
+ <div class="sec-nav-sep"></div>
133
+ <div style="padding:10px 12px;border-radius:10px;border:1px solid var(--line);background:var(--panel);display:flex;flex-direction:column;gap:6px">
134
+ <div class="small muted" style="font-weight:700;font-size:11.5px;text-transform:uppercase;letter-spacing:.04em">Резервная копия базы</div>
135
+ <button type="button" class="btn btn-ghost btn-sm" style="width:100%;justify-content:flex-start;font-size:12px;padding:6px 10px" data-action="admin-export-backup">📥 Скачать бэкап (.json)</button>
136
+ <button type="button" class="btn btn-ghost btn-sm" style="width:100%;justify-content:flex-start;font-size:12px;padding:6px 10px" data-action="admin-import-backup">📤 Восстановить из файла</button>
137
+ <button type="button" class="btn btn-ghost btn-sm" style="width:100%;justify-content:flex-start;font-size:12px;padding:6px 10px" data-action="admin-recover-npx" title="Найти и объединить данные из предыдущих запусков npx">🔄 Найти данные из npx</button>
138
+ <input type="file" id="inpAdmBackupFile" accept=".json,application/json" style="display:none">
139
+ <div class="small muted" id="admDataDirLabel" style="font-size:11px;word-break:break-all;line-height:1.3;margin-top:2px"></div>
140
+ </div>
132
141
  </aside>
133
142
 
134
143
  <!-- Правая область выбранного раздела -->
package/src/index.js CHANGED
@@ -37,7 +37,13 @@ export {
37
37
  parseOwnerAccessToken,
38
38
  verifyOwnerAccessToken,
39
39
  } from './crypto.js';
40
- export { LicenseStore } from './store.js';
40
+ export {
41
+ LicenseStore,
42
+ isEphemeralPackagePath,
43
+ resolvePersistentDataDir,
44
+ findCandidateRecoveryDirs,
45
+ mergeStoreStates,
46
+ } from './store.js';
41
47
  export { YooKassaService } from './yookassa.js';
42
48
  export { SupportBotsHub } from './bots.js';
43
49
  export { LicenseServerAiAgent } from './agent.js';
package/src/landing.js CHANGED
@@ -200,7 +200,13 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
200
200
  <span class="kicker">Платформа цифровой организации</span>
201
201
  <h1 class="h2" style="font-size:clamp(1.9rem,3vw,2.6rem);margin:4px 0 0">ODANT-WORK — учёт, документооборот, процессы и&nbsp;<span class="grad">приватный ИИ</span></h1>
202
202
  </div>
203
- <p>Все данные предприятия — справочники, проводки, документы, переписка и задачи — живут в единой модели с общими правами доступа и работают автономно в вашем контуре без передачи данных наружу.</p>
203
+ <div>
204
+ <p style="margin:0 0 12px">Все данные предприятия — справочники, проводки, документы, переписка и задачи — живут в единой модели с общими правами доступа и работают автономно в вашем контуре без передачи данных наружу.</p>
205
+ <div style="display:flex;gap:10px;flex-wrap:wrap">
206
+ <button type="button" class="btn btn-primary btn-sm" data-action="open-presentation" data-slide="1">${icon('docs')} Смотреть презентацию (12 слайдов)</button>
207
+ <a class="btn btn-ghost btn-sm" href="/assets/ODANT-WORK-presentation.pdf" download="ODANT-WORK-presentation.pdf" style="text-decoration:none">📥 Скачать PDF</a>
208
+ </div>
209
+ </div>
204
210
  </div>
205
211
 
206
212
  <div class="bento">
@@ -262,9 +268,10 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
262
268
  <h2 class="h2">ODANT-WORK · ${esc(P.sku)}</h2>
263
269
  <div class="price-big">${rub(P.basePriceRub)}</div>
264
270
  <p class="note">Вечная лицензия на ${P.baseSeats} одновременных рабочих сессий и ${P.includedSupportYears} год поддержки уровня Enterprise — в комплекте с сервером (или ${rub(P.baseLicensePriceRub || 4_000_000)} за базовую лицензию без оборудования при установке через <code>npm</code>).</p>
265
- <div class="hero-cta" style="margin-top:20px">
271
+ <div class="hero-cta" style="margin-top:20px;display:flex;gap:10px;flex-wrap:wrap">
266
272
  <button class="btn btn-primary" data-stage="clients">Перейти к покупке →</button>
267
273
  <button class="btn btn-ghost" data-stage="franchise">Региональная франшиза</button>
274
+ <button class="btn btn-ghost" data-action="open-presentation" data-slide="1">${icon('docs')} Презентация</button>
268
275
  </div>
269
276
  </div>
270
277
  <dl class="spec">
@@ -332,7 +339,13 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
332
339
  <span class="kicker">Тарифы поддержки и конкурентных мест</span>
333
340
  <h2 class="h2">Условия лицензирования и SLA</h2>
334
341
  </div>
335
- <p>Сразу после установки из <code>npm</code> система работает бесплатно в режиме настройки (1 сессия BOSS, ИИ-агенты отключены). Базовая постоянная лицензия открывает <b>${P.baseSeats} вечных одновременных сессий</b> и включает агентскую ИИ-систему.</p>
342
+ <div>
343
+ <p style="margin:0 0 12px">Сразу после установки из <code>npm</code> система работает бесплатно в режиме настройки (1 сессия BOSS, ИИ-агенты отключены). Базовая постоянная лицензия открывает <b>${P.baseSeats} вечных одновременных сессий</b> и включает агентскую ИИ-систему.</p>
344
+ <div style="display:flex;gap:10px;flex-wrap:wrap">
345
+ <button type="button" class="btn btn-ghost btn-sm" data-action="open-presentation" data-slide="8">${icon('docs')} Презентация для клиентов (слайд 8)</button>
346
+ <a class="btn btn-ghost btn-sm" href="/assets/ODANT-WORK-presentation.pdf" download="ODANT-WORK-presentation.pdf" style="text-decoration:none">📥 Скачать PDF</a>
347
+ </div>
348
+ </div>
336
349
  </div>
337
350
  <div class="tiers">${renderTiers()}</div>
338
351
  </section>
@@ -501,7 +514,13 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
501
514
 
502
515
  <div class="sec-head" style="margin-top:8px">
503
516
  <div><span class="kicker">Партнёрская программа</span><h2 class="h2">Условия региональной франшизы ODANT-WORK</h2></div>
504
- <p>Число франшиз в субъекте пропорционально населению, паушальный взнос — экономике региона. Резерв закрепляется сразу после невозвратного залога ${rub(FRANCHISE_PROGRAM.reservationDepositRub)}, полная оплата паушального взноса — в течение 1 года.</p>
517
+ <div>
518
+ <p style="margin:0 0 12px">Число франшиз в субъекте пропорционально населению, паушальный взнос — экономике региона. Резерв закрепляется сразу после невозвратного залога ${rub(FRANCHISE_PROGRAM.reservationDepositRub)}, полная оплата паушального взноса — в течение 1 года.</p>
519
+ <div style="display:flex;gap:10px;flex-wrap:wrap">
520
+ <button type="button" class="btn btn-ghost btn-sm" data-action="open-presentation" data-slide="10">${icon('docs')} Презентация франшизы (слайд 10)</button>
521
+ <a class="btn btn-ghost btn-sm" href="/assets/ODANT-WORK-presentation.pdf" download="ODANT-WORK-presentation.pdf" style="text-decoration:none">📥 Скачать PDF</a>
522
+ </div>
523
+ </div>
505
524
  </div>
506
525
 
507
526
  <div class="split">
@@ -548,7 +567,7 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
548
567
  <h4>${esc(VENDOR_CONTACTS.company)}</h4>
549
568
  <p>${esc(VENDOR_CONTACTS.address)}</p>
550
569
  <p>${VENDOR_CONTACTS.phones.map(esc).join(', ')}</p>
551
- <p><a href="${esc(VENDOR_CONTACTS.site)}" target="_blank" rel="noopener">${esc(VENDOR_CONTACTS.site.replace(/^https?:\/\//, ''))}</a> · <a href="#" data-action="open-oferta">Публичная оферта</a></p>
570
+ <p><a href="${esc(VENDOR_CONTACTS.site)}" target="_blank" rel="noopener">${esc(VENDOR_CONTACTS.site.replace(/^https?:\/\//, ''))}</a> · <a href="#" data-action="open-oferta">Публичная оферта</a> · <a href="#" data-action="open-presentation" data-slide="1">Презентация</a> · <a href="/assets/ODANT-WORK-presentation.pdf" download="ODANT-WORK-presentation.pdf">PDF</a></p>
552
571
  </div>
553
572
  </div>
554
573
  <div class="footer-bottom">
@@ -667,6 +686,50 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
667
686
  </div>
668
687
  </div>
669
688
 
689
+ <!-- Интерактивный просмотр презентации ODANT-WORK (для клиентов и франчайзи) -->
690
+ <div class="modal pres-modal" id="presentationModal" role="dialog" aria-modal="true" aria-labelledby="presModalTitle">
691
+ <div class="card pres-card">
692
+ <div class="pres-top">
693
+ <div class="pres-title-wrap">
694
+ <img src="/assets/odant-logo.png" alt="">
695
+ <div>
696
+ <b id="presModalTitle">Презентация ODANT-WORK</b>
697
+ <span class="small muted">Для клиентов и региональных партнёров (франчайзи)</span>
698
+ </div>
699
+ </div>
700
+ <div class="pres-sections" id="presSectionTabs">
701
+ <button type="button" class="chip on" data-action="pres-goto" data-slide="1" data-sec="product">1. О платформе (1–7)</button>
702
+ <button type="button" class="chip" data-action="pres-goto" data-slide="8" data-sec="clients">2. Клиентам (8–9)</button>
703
+ <button type="button" class="chip" data-action="pres-goto" data-slide="10" data-sec="franchise">3. Франшиза (10–12)</button>
704
+ </div>
705
+ <div class="pres-actions">
706
+ <a class="btn btn-ghost btn-sm" href="/presentation" target="_blank" rel="noopener" style="text-decoration:none" title="Открыть в отдельном окне">${icon('expand')} Во весь экран</a>
707
+ <a class="btn btn-primary btn-sm" href="/assets/ODANT-WORK-presentation.pdf" download="ODANT-WORK-presentation.pdf" style="text-decoration:none">📥 Скачать PDF</a>
708
+ <button type="button" class="icon-btn" data-action="pres-close" aria-label="Закрыть презентацию">${icon('close')}</button>
709
+ </div>
710
+ </div>
711
+
712
+ <div class="pres-stage">
713
+ <iframe id="presFrame" title="Слайды презентации ODANT-WORK" loading="lazy"></iframe>
714
+ </div>
715
+
716
+ <div class="pres-bottom">
717
+ <div style="display:flex;align-items:center;gap:8px">
718
+ <button type="button" class="btn btn-ghost btn-sm" data-action="pres-prev">← Назад</button>
719
+ <span class="pres-counter" id="presCounter">Слайд 1 / 12</span>
720
+ <button type="button" class="btn btn-ghost btn-sm" data-action="pres-next">Вперёд →</button>
721
+ </div>
722
+ <div class="pres-dots" id="presDots">
723
+ ${Array.from({ length: 12 }, (_, idx) => `<button type="button" class="pres-dot ${idx === 0 ? 'on' : ''}" data-action="pres-goto" data-slide="${idx + 1}" title="Слайд ${idx + 1}">${idx + 1}</button>`).join('')}
724
+ </div>
725
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
726
+ <button type="button" class="btn btn-ghost btn-sm" data-action="pres-jump-stage" data-target-stage="clients">К калькулятору клиентов →</button>
727
+ <button type="button" class="btn btn-ghost btn-sm" data-action="pres-jump-stage" data-target-stage="franchise">К карте франшиз РФ →</button>
728
+ </div>
729
+ </div>
730
+ </div>
731
+ </div>
732
+
670
733
  <!-- ИИ-агент лицензионного центра -->
671
734
  <button class="ai-fab" data-action="ai-toggle" aria-controls="aiDrawer"><img src="/assets/odant-logo.png" alt=""><span class="t">ИИ-агент ODANT</span><span class="live"></span></button>
672
735
  <div class="ai-drawer" id="aiDrawer" aria-live="polite">
package/src/server.js CHANGED
@@ -39,16 +39,14 @@ import {
39
39
  calculateRegionPartnerQuota,
40
40
  PUBLIC_OFERTA,
41
41
  } from './catalog.js';
42
- import { LicenseStore } from './store.js';
42
+ import { LicenseStore, resolvePersistentDataDir } from './store.js';
43
43
  import { YooKassaService } from './yookassa.js';
44
44
  import { SupportBotsHub } from './bots.js';
45
45
  import { LicenseServerAiAgent } from './agent.js';
46
46
  import { renderLandingPage, renderSandboxPaymentPage } from './landing.js';
47
47
  import { renderAdminPage } from './admin.js';
48
48
 
49
- const DEFAULT_DATA_DIR = fileURLToPath(new URL('../data', import.meta.url));
50
-
51
- async function readJsonBody(req, maxBytes = 1024 * 1024) {
49
+ async function readJsonBody(req, maxBytes = 16 * 1024 * 1024) {
52
50
  const chunks = [];
53
51
  let size = 0;
54
52
  for await (const ch of req) {
@@ -82,6 +80,9 @@ const STATIC_ASSETS = Object.freeze({
82
80
  '/assets/landing.css': { file: 'landing.css', type: 'text/css; charset=utf-8' },
83
81
  '/assets/landing.js': { file: 'landing.js', type: 'text/javascript; charset=utf-8' },
84
82
  '/assets/admin.js': { file: 'admin.js', type: 'text/javascript; charset=utf-8' },
83
+ '/presentation': { file: 'ODANT-WORK-presentation.html', type: 'text/html; charset=utf-8' },
84
+ '/assets/ODANT-WORK-presentation.html': { file: 'ODANT-WORK-presentation.html', type: 'text/html; charset=utf-8' },
85
+ '/assets/ODANT-WORK-presentation.pdf': { file: 'ODANT-WORK-presentation.pdf', type: 'application/pdf' },
85
86
  });
86
87
  const assetCache = new Map();
87
88
  let assetVersionCache = '';
@@ -281,10 +282,12 @@ async function sendOtpEmail({ to, code }) {
281
282
  export function createLicenseServerApp(options = {}) {
282
283
  const pendingOtpCodes = new Map(); // email -> { code, expiresAt, attempts }
283
284
  const store = options.store || new LicenseStore({
284
- dataDir: options.dataDir || DEFAULT_DATA_DIR,
285
+ dataDir: options.dataDir || resolvePersistentDataDir(),
285
286
  adminLogin: options.adminLogin,
286
287
  adminPassword: options.adminPassword,
287
288
  publicOrigin: options.publicOrigin || 'https://work.odant.org',
289
+ autoRecover: options.autoRecover,
290
+ extraSearchRoots: options.extraSearchRoots,
288
291
  });
289
292
  let bots = null;
290
293
  const yookassa = options.yookassa || new YooKassaService({
@@ -983,6 +986,8 @@ export function createLicenseServerApp(options = {}) {
983
986
  return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
984
987
  const allFranchises = store.listAllFranchises();
985
988
  return sendJson(res, 200, {
989
+ dataDir: store.dataDir,
990
+ counts: store.getStoreCounts(),
986
991
  licenses: store.listLicenses(),
987
992
  tickets: store.listTickets(),
988
993
  orders: Object.values(store.state.orders).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)),
@@ -994,6 +999,40 @@ export function createLicenseServerApp(options = {}) {
994
999
  });
995
1000
  }
996
1001
 
1002
+ if (req.method === 'GET' && pathname === '/api/admin/backup/export') {
1003
+ const tok = extractBearerToken(req, null, url);
1004
+ if (!store.verifyAdminToken(tok))
1005
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
1006
+ const bundle = store.exportBackupBundle();
1007
+ const dayStr = new Date().toISOString().slice(0, 10);
1008
+ const body = JSON.stringify(bundle, null, 2);
1009
+ res.writeHead(200, {
1010
+ 'Content-Type': 'application/json; charset=utf-8',
1011
+ 'Content-Disposition': `attachment; filename="odant-work-license-backup-${dayStr}.json"`,
1012
+ 'Access-Control-Allow-Origin': '*',
1013
+ });
1014
+ res.end(body);
1015
+ return;
1016
+ }
1017
+
1018
+ if (req.method === 'POST' && pathname === '/api/admin/backup/import') {
1019
+ const body = await readJsonBody(req);
1020
+ const tok = extractBearerToken(req, body, url);
1021
+ if (!store.verifyAdminToken(tok))
1022
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
1023
+ const result = store.importBackupBundle(body.backup || body, { mode: body.mode || 'merge' });
1024
+ return sendJson(res, 200, result);
1025
+ }
1026
+
1027
+ if (req.method === 'POST' && pathname === '/api/admin/backup/recover') {
1028
+ const body = await readJsonBody(req);
1029
+ const tok = extractBearerToken(req, body, url);
1030
+ if (!store.verifyAdminToken(tok))
1031
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
1032
+ const result = store.recoverFromExternalLocations({ extraSearchRoots: body.extraSearchRoots || [] });
1033
+ return sendJson(res, 200, result);
1034
+ }
1035
+
997
1036
  if (req.method === 'POST' && pathname === '/api/admin/servers/manage') {
998
1037
  const body = await readJsonBody(req);
999
1038
  const tok = extractBearerToken(req, body, url);