work-license-server 0.0.0-stage → 1.0.0

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/src/landing.js ADDED
@@ -0,0 +1,981 @@
1
+ /**
2
+ * Генератор страниц лендинга ПАК WORK (work.odant.org), калькулятора лицензий,
3
+ * песочницы оплаты ЮKassa и кабинета техподдержки (MAX, Telegram, VK).
4
+ */
5
+ import { PAK_PRODUCT, SLA_TIERS, FRANCHISE_PROGRAM, REGIONS_CATALOG, PARTNER_TRAINING_TRACKS } from './catalog.js';
6
+
7
+ export function renderLandingPage({ publicKey = '' } = {}) {
8
+ const productJson = JSON.stringify(PAK_PRODUCT);
9
+ const tiersJson = JSON.stringify(Object.values(SLA_TIERS));
10
+ const regionsJson = JSON.stringify(REGIONS_CATALOG);
11
+ const tracksJson = JSON.stringify(PARTNER_TRAINING_TRACKS);
12
+ const franchiseJson = JSON.stringify(FRANCHISE_PROGRAM);
13
+
14
+ return /* html */`<!DOCTYPE html>
15
+ <html lang="ru">
16
+ <head>
17
+ <meta charset="utf-8">
18
+ <meta name="viewport" content="width=device-width, initial-scale=1">
19
+ <title>WORK ПАК — Программно-аппаратный комплекс цифровой организации · work.odant.org</title>
20
+ <style>
21
+ :root {
22
+ --bg: #0b1120;
23
+ --surface: #111827;
24
+ --surface-2: #1e293b;
25
+ --border: rgba(148, 163, 184, 0.18);
26
+ --text: #f8fafc;
27
+ --muted: #94a3b8;
28
+ --accent: #3b82f6;
29
+ --accent-2: #10b981;
30
+ --warn: #f59e0b;
31
+ }
32
+ * { box-sizing: border-box; }
33
+ body {
34
+ margin: 0;
35
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif;
36
+ background: var(--bg);
37
+ color: var(--text);
38
+ line-height: 1.55;
39
+ }
40
+ a { color: var(--accent); text-decoration: none; }
41
+ .container {
42
+ max-width: 1180px;
43
+ margin: 0 auto;
44
+ padding: 0 24px;
45
+ }
46
+ header {
47
+ position: sticky;
48
+ top: 0;
49
+ z-index: 20;
50
+ backdrop-filter: blur(14px);
51
+ background: rgba(11, 17, 32, 0.84);
52
+ border-bottom: 1px solid var(--border);
53
+ }
54
+ .nav {
55
+ display: flex;
56
+ align-items: center;
57
+ justify-content: space-between;
58
+ height: 68px;
59
+ gap: 16px;
60
+ }
61
+ .brand {
62
+ display: flex;
63
+ align-items: center;
64
+ gap: 12px;
65
+ font-weight: 800;
66
+ font-size: 20px;
67
+ letter-spacing: .02em;
68
+ }
69
+ .brand-badge {
70
+ padding: 3px 10px;
71
+ border-radius: 999px;
72
+ font-size: 12px;
73
+ background: rgba(59, 130, 246, 0.2);
74
+ color: #60a5fa;
75
+ border: 1px solid rgba(59, 130, 246, 0.35);
76
+ }
77
+ .nav-links {
78
+ display: flex;
79
+ gap: 22px;
80
+ font-size: 14px;
81
+ color: var(--muted);
82
+ }
83
+ .nav-links a:hover { color: #fff; }
84
+ .hero {
85
+ padding: 76px 0 64px;
86
+ background:
87
+ radial-gradient(900px 420px at 80% 0%, rgba(59, 130, 246, 0.22), transparent 65%),
88
+ radial-gradient(700px 380px at 15% 20%, rgba(16, 185, 129, 0.14), transparent 65%);
89
+ border-bottom: 1px solid var(--border);
90
+ }
91
+ .hero-grid {
92
+ display: grid;
93
+ grid-template-columns: 1.25fr 0.95fr;
94
+ gap: 36px;
95
+ align-items: center;
96
+ }
97
+ @media (max-width: 920px) {
98
+ .hero-grid { grid-template-columns: 1fr; }
99
+ }
100
+ .eyebrow {
101
+ display: inline-block;
102
+ padding: 5px 12px;
103
+ border-radius: 999px;
104
+ background: rgba(16, 185, 129, 0.14);
105
+ color: #34d399;
106
+ font-size: 12px;
107
+ font-weight: 700;
108
+ letter-spacing: .06em;
109
+ text-transform: uppercase;
110
+ margin-bottom: 16px;
111
+ }
112
+ h1 {
113
+ margin: 0 0 18px;
114
+ font-size: clamp(2.1rem, 4.2vw, 3.25rem);
115
+ line-height: 1.1;
116
+ letter-spacing: -0.025em;
117
+ }
118
+ .lead {
119
+ color: var(--muted);
120
+ font-size: 17px;
121
+ margin: 0 0 28px;
122
+ }
123
+ .spec-card {
124
+ background: linear-gradient(145deg, #131c31, #0f172a);
125
+ border: 1px solid rgba(59, 130, 246, 0.32);
126
+ border-radius: 18px;
127
+ padding: 26px;
128
+ box-shadow: 0 24px 50px rgba(0, 0, 0, 0.45);
129
+ }
130
+ .spec-price {
131
+ font-size: 34px;
132
+ font-weight: 900;
133
+ color: #60a5fa;
134
+ margin: 6px 0;
135
+ }
136
+ .spec-list {
137
+ list-style: none;
138
+ padding: 0;
139
+ margin: 16px 0 0;
140
+ display: flex;
141
+ flex-direction: column;
142
+ gap: 10px;
143
+ font-size: 14px;
144
+ }
145
+ .spec-list li {
146
+ display: flex;
147
+ justify-content: space-between;
148
+ gap: 12px;
149
+ padding-bottom: 8px;
150
+ border-bottom: 1px dashed var(--border);
151
+ }
152
+ section {
153
+ padding: 64px 0;
154
+ border-bottom: 1px solid var(--border);
155
+ }
156
+ h2 {
157
+ margin: 0 0 10px;
158
+ font-size: 28px;
159
+ }
160
+ .sub {
161
+ color: var(--muted);
162
+ margin: 0 0 32px;
163
+ }
164
+ .grid-4 {
165
+ display: grid;
166
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
167
+ gap: 18px;
168
+ }
169
+ .grid-3 {
170
+ display: grid;
171
+ grid-template-columns: repeat(auto-fit, minmax(310px, 1fr));
172
+ gap: 20px;
173
+ }
174
+ .card {
175
+ background: var(--surface);
176
+ border: 1px solid var(--border);
177
+ border-radius: 14px;
178
+ padding: 22px;
179
+ display: flex;
180
+ flex-direction: column;
181
+ gap: 10px;
182
+ }
183
+ .card.featured {
184
+ border-color: var(--accent);
185
+ box-shadow: 0 0 0 1px var(--accent);
186
+ }
187
+ .pill {
188
+ align-self: flex-start;
189
+ padding: 3px 10px;
190
+ border-radius: 999px;
191
+ font-size: 11px;
192
+ font-weight: 700;
193
+ background: rgba(59, 130, 246, 0.16);
194
+ color: #93c5fd;
195
+ }
196
+ .tier-price {
197
+ font-size: 28px;
198
+ font-weight: 800;
199
+ color: #fff;
200
+ }
201
+ .tier-price span {
202
+ font-size: 14px;
203
+ font-weight: 400;
204
+ color: var(--muted);
205
+ }
206
+ .feature-ul {
207
+ margin: 8px 0 0;
208
+ padding-left: 18px;
209
+ color: var(--muted);
210
+ font-size: 14px;
211
+ display: flex;
212
+ flex-direction: column;
213
+ gap: 6px;
214
+ }
215
+ .calc-box {
216
+ display: grid;
217
+ grid-template-columns: 1.1fr 0.9fr;
218
+ gap: 24px;
219
+ background: var(--surface);
220
+ border: 1px solid var(--border);
221
+ border-radius: 16px;
222
+ padding: 28px;
223
+ }
224
+ @media (max-width: 860px) {
225
+ .calc-box { grid-template-columns: 1fr; }
226
+ }
227
+ .form-row {
228
+ display: flex;
229
+ flex-direction: column;
230
+ gap: 6px;
231
+ margin-bottom: 14px;
232
+ }
233
+ .form-row label {
234
+ font-size: 13px;
235
+ color: var(--muted);
236
+ font-weight: 600;
237
+ }
238
+ input[type="text"], input[type="email"], input[type="number"], select, textarea {
239
+ background: var(--surface-2);
240
+ border: 1px solid var(--border);
241
+ color: #fff;
242
+ padding: 10px 12px;
243
+ border-radius: 8px;
244
+ font-size: 14px;
245
+ width: 100%;
246
+ }
247
+ .btn {
248
+ display: inline-flex;
249
+ align-items: center;
250
+ justify-content: center;
251
+ gap: 8px;
252
+ padding: 12px 22px;
253
+ border-radius: 10px;
254
+ font-weight: 700;
255
+ font-size: 15px;
256
+ cursor: pointer;
257
+ border: none;
258
+ transition: opacity .15s, transform .1s;
259
+ }
260
+ .btn:hover { opacity: 0.94; }
261
+ .btn-primary {
262
+ background: var(--accent);
263
+ color: #fff;
264
+ }
265
+ .btn-success {
266
+ background: var(--accent-2);
267
+ color: #052e16;
268
+ }
269
+ .btn-outline {
270
+ background: transparent;
271
+ border: 1px solid var(--border);
272
+ color: var(--text);
273
+ }
274
+ .summary-panel {
275
+ background: var(--surface-2);
276
+ border-radius: 12px;
277
+ padding: 22px;
278
+ display: flex;
279
+ flex-direction: column;
280
+ justify-content: space-between;
281
+ gap: 16px;
282
+ }
283
+ .total-sum {
284
+ font-size: 32px;
285
+ font-weight: 900;
286
+ color: #34d399;
287
+ }
288
+ .bot-console {
289
+ display: grid;
290
+ grid-template-columns: 1fr 1fr;
291
+ gap: 20px;
292
+ }
293
+ @media (max-width: 860px) {
294
+ .bot-console { grid-template-columns: 1fr; }
295
+ }
296
+ .log-box {
297
+ background: #070b14;
298
+ border: 1px solid var(--border);
299
+ border-radius: 10px;
300
+ padding: 14px;
301
+ font-family: monospace;
302
+ font-size: 12.5px;
303
+ min-height: 180px;
304
+ max-height: 300px;
305
+ overflow-y: auto;
306
+ white-space: pre-wrap;
307
+ }
308
+ footer {
309
+ padding: 36px 0;
310
+ text-align: center;
311
+ color: var(--muted);
312
+ font-size: 13px;
313
+ }
314
+ </style>
315
+ </head>
316
+ <body>
317
+ <header>
318
+ <div class="container nav">
319
+ <div class="brand">
320
+ <span>WORK ПАК</span>
321
+ <span class="brand-badge">work.odant.org</span>
322
+ </div>
323
+ <nav class="nav-links">
324
+ <a href="#hardware">Аппаратный комплекс</a>
325
+ <a href="#models">Предустановленный ИИ</a>
326
+ <a href="#pricing">Лицензии и SLA</a>
327
+ <a href="#checkout">Калькулятор и ЮKassa</a>
328
+ <a href="#franchise">Региональная франшиза</a>
329
+ <a href="#support">Боты MAX · TG · VK</a>
330
+ </nav>
331
+ </div>
332
+ </header>
333
+
334
+ <div class="hero" id="hardware">
335
+ <div class="container hero-grid">
336
+ <div>
337
+ <span class="eyebrow">Программно-аппаратный комплекс предприятия</span>
338
+ <h1>Учёт уровня 1С и приватный мультимодальный ИИ в одной коробке</h1>
339
+ <p class="lead">
340
+ Автономный сервер предприятия на базе <b>AMD Ryzen 495 8085 · 192 ГБ RAM · SSD 4 ТБ</b> с предустановленной типовой конфигурацией WORK, 4 локальными моделями ИИ и <b>вечной лицензией на 5 одновременных рабочих мест</b> (+1 год поддержки включён).
341
+ </p>
342
+ <div style="display:flex; gap:12px; flex-wrap:wrap;">
343
+ <a href="#checkout" class="btn btn-primary">Заказать ПАК / Докупить рабочие места</a>
344
+ <a href="#support" class="btn btn-outline">Подключить боты поддержки (MAX, TG, VK)</a>
345
+ </div>
346
+ </div>
347
+
348
+ <div class="spec-card">
349
+ <div style="font-size:13px; color:var(--muted); text-transform:uppercase; letter-spacing:.06em;">Базовый комплект ПАК WORK</div>
350
+ <div class="spec-price">5 000 000 ₽</div>
351
+ <div style="font-size:13px; color:#34d399; font-weight:600;">Вечная лицензия на 5 рабочих мест + 1 год поддержки в комплекте</div>
352
+ <ul class="spec-list">
353
+ <li><span>Процессор</span><strong>AMD Ryzen 495 8085</strong></li>
354
+ <li><span>Оперативная память</span><strong>192 ГБ Unified RAM</strong></li>
355
+ <li><span>Накопитель</span><strong>SSD 4 ТБ NVMe</strong></li>
356
+ <li><span>Учётная система</span><strong>Типовая конфигурация (аналог 1С)</strong></li>
357
+ <li><span>Локальный ИИ</span><strong>Qwen 3 30B + Голос + Картинки + Embedding</strong></li>
358
+ <li><span>Лицензирование</span><strong>5 вечных мест (конкурентные сессии)</strong></li>
359
+ <li><span>Защита лицензии</span><strong>HWID Ryzen + #system/node-key.json</strong></li>
360
+ </ul>
361
+ </div>
362
+ </div>
363
+ </div>
364
+
365
+ <section id="models">
366
+ <div class="container">
367
+ <h2>Предустановленные модели ИИ и типовая конфигурация</h2>
368
+ <p class="sub">Все модели работают локально в 192 ГБ памяти ПАК без передачи коммерческих данных во внешние облака.</p>
369
+ <div class="grid-4" id="modelsGrid"></div>
370
+ </div>
371
+ </section>
372
+
373
+ <section id="pricing">
374
+ <div class="container">
375
+ <h2>Дополнительные годовые лицензии и уровни поддержки (SLA)</h2>
376
+ <p class="sub">Базовые 5 рабочих мест — вечные. Дополнительные рабочие места лицензируются на 1 год и зависят от выбранного уровня поддержки.</p>
377
+ <div class="grid-3" id="tiersGrid"></div>
378
+ </div>
379
+ </section>
380
+
381
+ <section id="checkout">
382
+ <div class="container">
383
+ <h2>Калькулятор заказа и оплата через ЮKassa</h2>
384
+ <p class="sub">Оформите поставку базового ПАК WORK или докупите срочные годовые лицензии на дополнительных пользователей.</p>
385
+ <div class="calc-box">
386
+ <div>
387
+ <div class="form-row">
388
+ <label style="display:flex; align-items:center; gap:8px; color:#fff; cursor:pointer;">
389
+ <input type="checkbox" id="chkBasePak" onchange="recalc()">
390
+ <span>Включить базовый комплект ПАК WORK (AMD Ryzen 495 8085 192GB 4TB + 5 вечных мест) — 5 000 000 ₽</span>
391
+ </label>
392
+ </div>
393
+ <div class="form-row">
394
+ <label>Уровень поддержки (SLA) для дополнительных лицензий</label>
395
+ <select id="selTier" onchange="recalc()">
396
+ <option value="start">Start — 15 000 ₽ / год за 1 пользователя (реакция до 8 ч, 12 обращений)</option>
397
+ <option value="business" selected>Business — 40 000 ₽ / год за 1 пользователя (реакция до 2 ч, 48 обращений)</option>
398
+ <option value="enterprise">Enterprise — 100 000 ₽ / год за 1 пользователя (реакция до 15 мин 24/7, безлимит)</option>
399
+ </select>
400
+ </div>
401
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
402
+ <div class="form-row">
403
+ <label>Дополнительных рабочих мест (пользователей)</label>
404
+ <input type="number" id="inpSeats" min="0" max="500" value="3" oninput="recalc()">
405
+ </div>
406
+ <div class="form-row">
407
+ <label>Срок подписки (лет)</label>
408
+ <input type="number" id="inpYears" min="1" max="5" value="1" oninput="recalc()">
409
+ </div>
410
+ </div>
411
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
412
+ <div class="form-row">
413
+ <label>Организация (или ФИО)</label>
414
+ <input type="text" id="inpOrg" placeholder="ООО «Маслоторг»" value="ООО «Маслоторг»">
415
+ </div>
416
+ <div class="form-row">
417
+ <label>ИНН организации</label>
418
+ <input type="text" id="inpInn" placeholder="7701234567" value="7701234567">
419
+ </div>
420
+ </div>
421
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
422
+ <div class="form-row">
423
+ <label>Email для чека ЮKassa и ключа лицензии</label>
424
+ <input type="email" id="inpEmail" placeholder="admin@company.ru" value="admin@maslotorg.ru">
425
+ </div>
426
+ <div class="form-row">
427
+ <label>Номер лицензии ПАК или HWID (если уже есть ПАК)</label>
428
+ <input type="text" id="inpLicId" placeholder="Оставьте пустым для выпуска новой">
429
+ </div>
430
+ </div>
431
+ </div>
432
+
433
+ <div class="summary-panel">
434
+ <div>
435
+ <div style="font-size:13px; color:var(--muted); text-transform:uppercase;">Спецификация заказа</div>
436
+ <div class="total-sum" id="lblTotal">120 000 ₽</div>
437
+ <div id="lblBreakdown" style="font-size:14px; color:var(--muted); margin-top:8px;"></div>
438
+ </div>
439
+ <div style="display:flex; flex-direction:column; gap:10px;">
440
+ <button class="btn btn-primary" onclick="submitOrder()">Перейти к оплате через ЮKassa</button>
441
+ <div id="orderStatus" style="font-size:13px; color:#93c5fd;"></div>
442
+ </div>
443
+ </div>
444
+ </div>
445
+ </div>
446
+ </section>
447
+
448
+ <section id="franchise">
449
+ <div class="container">
450
+ <h2>Партнёрская программа и Региональная франшиза WORK</h2>
451
+ <p class="sub">
452
+ Партнёр самостоятельно закупает оборудование ПАК, устанавливает стек WORK и модели ИИ, привязывает железо, выдаёт лицензии и предоставляет <b>годовую гарантию на оборудование</b>.
453
+ Фиксированная стоимость франшизы пропорциональна экономике региона, квота партнёров — населению, а партнёрская скидка растёт от <b>10%</b> до <b>50%</b> пропорционально годовому объёму продаж.
454
+ </p>
455
+ <div class="calc-box" style="margin-bottom:28px;">
456
+ <div>
457
+ <div class="form-row">
458
+ <label>Регион РФ (стоимость франшизы — по экономике региона, квота мест — по населению)</label>
459
+ <select id="selRegion" onchange="recalcFranchise()"></select>
460
+ </div>
461
+ <div class="form-row">
462
+ <label>Плановый годовой объём продаж партнёра (млн ₽, 1 ПАК = 5 млн ₽)</label>
463
+ <input type="number" id="inpPartnerSalesM" min="0" max="200" step="5" value="25" oninput="recalcFranchise()">
464
+ </div>
465
+ <div class="form-row">
466
+ <label>Дополнительный доход партнёра на каждом внедрении (100% выручка партнёра):</label>
467
+ <div style="display:flex; flex-direction:column; gap:6px; font-size:13px; color:#e2e8f0; margin-top:4px;">
468
+ <label style="display:flex; align-items:center; gap:8px; color:#fff; font-weight:400;">
469
+ <input type="checkbox" id="chkAddNas" checked onchange="recalcFranchise()">
470
+ <span>Поставка NAS для резервного копирования (+350 000 ₽)</span>
471
+ </label>
472
+ <label style="display:flex; align-items:center; gap:8px; color:#fff; font-weight:400;">
473
+ <input type="checkbox" id="chkAddInfer" onchange="recalcFranchise()">
474
+ <span>Доп. оборудование для инференса (+1 200 000 ₽)</span>
475
+ </label>
476
+ <label style="display:flex; align-items:center; gap:8px; color:#fff; font-weight:400;">
477
+ <input type="checkbox" id="chkAddApi" checked onchange="recalcFranchise()">
478
+ <span>Собственный платный API партнёра к моделям ИИ (+360 000 ₽/год)</span>
479
+ </label>
480
+ <label style="display:flex; align-items:center; gap:8px; color:#fff; font-weight:400;">
481
+ <input type="checkbox" id="chkAddSup" checked onchange="recalcFranchise()">
482
+ <span>Техническая поддержка и гарантийный сервис партнёра (+240 000 ₽/год)</span>
483
+ </label>
484
+ <label style="display:flex; align-items:center; gap:8px; color:#fff; font-weight:400;">
485
+ <input type="checkbox" id="chkAddSkills" checked onchange="recalcFranchise()">
486
+ <span>Дообучение ИИ-агентов (LoRA) и разработка навыков skills (+600 000 ₽)</span>
487
+ </label>
488
+ </div>
489
+ </div>
490
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
491
+ <div class="form-row">
492
+ <label>Компания-кандидат в партнёры</label>
493
+ <input type="text" id="inpPartnerCompany" value="ООО «УралТехИнтеграция»">
494
+ </div>
495
+ <div class="form-row">
496
+ <label>ФИО специалиста для сертификации</label>
497
+ <input type="text" id="inpSpecName" value="Смирнов Андрей Викторович">
498
+ </div>
499
+ </div>
500
+ <div style="display:flex; gap:10px; flex-wrap:wrap; margin-top:8px;">
501
+ <button class="btn btn-primary" onclick="registerPartnerAction()">Зарегистрировать партнёра в регионе</button>
502
+ <button class="btn btn-success" onclick="partnerIssuePakAction()">Выдать лицензию ПАК от партнёра (1 г. гарантии)</button>
503
+ <button class="btn btn-outline" onclick="certifySpecialistAction()">Аттестовать и выдать сертификат</button>
504
+ </div>
505
+ <div id="partnerActionMsg" style="margin-top:10px; font-size:13px; color:#34d399;"></div>
506
+ </div>
507
+ <div class="summary-panel">
508
+ <div>
509
+ <div style="font-size:13px; color:var(--muted); text-transform:uppercase;">Фиксированный взнос региональной франшизы</div>
510
+ <div class="total-sum" id="lblFranchiseFee">1 400 000 ₽</div>
511
+ <div style="font-size:13px; color:#60a5fa; font-weight:700;" id="lblPartnerQuota"></div>
512
+ <div id="lblPartnerEcon" style="font-size:14px; color:var(--muted); margin-top:10px;"></div>
513
+ </div>
514
+ </div>
515
+ </div>
516
+
517
+ <h3 style="margin:0 0 14px;">Источники дохода регионального партнёра-франчайзи (база + 100% доп. услуги и оборудование)</h3>
518
+ <div class="grid-3" id="streamsGrid" style="margin-bottom:28px;"></div>
519
+
520
+ <h3 style="margin:0 0 14px;">Программа обучения и сертификации специалистов партнёров</h3>
521
+ <div class="grid-4" id="tracksGrid"></div>
522
+ </div>
523
+ </section>
524
+
525
+ <section id="support">
526
+ <div class="container">
527
+ <h2>Омниканальные боты поддержки (MAX · Telegram · VK) и проверка лицензии</h2>
528
+ <p class="sub">Клиент общается с поддержкой через удобный мессенджер (MAX, Telegram, VK). Бот автоматически проверяет лицензию ПАК, квоту обращений и контролирует время реакции по SLA.</p>
529
+ <div class="bot-console">
530
+ <div class="card">
531
+ <h3 style="margin:0;">Симулятор обращения в бот (MAX / Telegram / VK)</h3>
532
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
533
+ <div class="form-row">
534
+ <label>Мессенджер</label>
535
+ <select id="botChannel">
536
+ <option value="max">MAX (botapi.max.ru)</option>
537
+ <option value="telegram">Telegram</option>
538
+ <option value="vk">VK (ВКонтакте)</option>
539
+ </select>
540
+ </div>
541
+ <div class="form-row">
542
+ <label>Chat ID пользователя</label>
543
+ <input type="text" id="botChatId" value="client-101">
544
+ </div>
545
+ </div>
546
+ <div class="form-row">
547
+ <label>Команда или текст обращения (например: /start, /bind &lt;ID&gt;, /status, /buy business 2 или вопрос)</label>
548
+ <input type="text" id="botText" value="/start">
549
+ </div>
550
+ <div style="display:flex; gap:8px; flex-wrap:wrap;">
551
+ <button class="btn btn-primary" onclick="sendBotSim()">Отправить в бот</button>
552
+ <button class="btn btn-outline" onclick="setBotCmd('/status')">/status</button>
553
+ <button class="btn btn-outline" onclick="setBotCmd('/buy business 2')">/buy business 2</button>
554
+ <button class="btn btn-outline" onclick="setBotCmd('Помогите настроить обмен с банком в модуле Register')">Тестовый тикет</button>
555
+ </div>
556
+ <div class="log-box" id="botReplyBox">Ответ бота появится здесь…</div>
557
+ </div>
558
+
559
+ <div class="card">
560
+ <h3 style="margin:0;">Реестр выпущенных лицензий ПАК и тикетов SLA</h3>
561
+ <div style="display:flex; gap:8px;">
562
+ <button class="btn btn-outline" onclick="refreshRegistry()">Обновить статус</button>
563
+ <button class="btn btn-success" onclick="createDemoPak()">Выпустить базовую лицензию ПАК (5 мест)</button>
564
+ </div>
565
+ <div class="log-box" id="registryBox">Загрузка реестра…</div>
566
+ </div>
567
+ </div>
568
+ </div>
569
+ </section>
570
+
571
+ <footer>
572
+ <div class="container">
573
+ <div>WORK ПАК · work.odant.org · AMD Ryzen 495 8085 · 192 ГБ RAM · SSD 4 ТБ · Публичный ключ центра сертификации Ed25519: <code>${publicKey.slice(0, 28)}…</code></div>
574
+ </div>
575
+ </footer>
576
+
577
+ <script>
578
+ const PRODUCT = ${productJson};
579
+ const TIERS = ${tiersJson};
580
+ const REGIONS = ${regionsJson};
581
+ const TRACKS = ${tracksJson};
582
+ const FRANCHISE = ${franchiseJson};
583
+ let lastRegisteredPartnerId = '';
584
+
585
+ function fmtRub(n) {
586
+ return Number(n || 0).toLocaleString('ru-RU') + ' ₽';
587
+ }
588
+
589
+ function renderCatalog() {
590
+ const mGrid = document.getElementById('modelsGrid');
591
+ mGrid.innerHTML = PRODUCT.preinstalledModels.map(m => \`
592
+ <div class="card">
593
+ <span class="pill">\${m.badge}</span>
594
+ <h3 style="margin:4px 0 0;">\${m.title}</h3>
595
+ <p style="margin:0; color:var(--muted); font-size:14px;">\${m.description}</p>
596
+ <div style="margin-top:auto; padding-top:8px; font-size:12px; color:#60a5fa; font-family:monospace;">Выделение в памяти ПАК: ~\${m.ramGb} ГБ из 192 ГБ</div>
597
+ </div>
598
+ \`).join('');
599
+
600
+ const tGrid = document.getElementById('tiersGrid');
601
+ tGrid.innerHTML = TIERS.map(t => \`
602
+ <div class="card \${t.id === 'business' ? 'featured' : ''}">
603
+ <span class="pill">\${t.badge}</span>
604
+ <h3 style="margin:4px 0 0; font-size:22px;">\${t.name}</h3>
605
+ <div class="tier-price">\${fmtRub(t.pricePerUserYearRub)} <span>/ 1 польз. в год</span></div>
606
+ <div style="font-size:13px; color:#34d399; font-weight:600;">Реакция SLA: \${t.reactionLabel} (\${t.scheduleLabel})</div>
607
+ <ul class="feature-ul">
608
+ \${t.features.map(f => \`<li>\${f}</li>\`).join('')}
609
+ </ul>
610
+ <button class="btn \${t.id === 'business' ? 'btn-primary' : 'btn-outline'}" style="margin-top:auto;" onclick="chooseTier('\${t.id}')">Выбрать \${t.name}</button>
611
+ </div>
612
+ \`).join('');
613
+
614
+ const selReg = document.getElementById('selRegion');
615
+ if (selReg) {
616
+ selReg.innerHTML = REGIONS.map(r => \`
617
+ <option value="\${r.code}" \${r.code === 'RU-SVE' ? 'selected' : ''}>
618
+ \${r.name} (\${r.district}) — Население: \${r.populationMillions} млн чел. | Индекс экономики: \${r.economicIndex}
619
+ </option>
620
+ \`).join('');
621
+ }
622
+
623
+ const trGrid = document.getElementById('tracksGrid');
624
+ if (trGrid) {
625
+ trGrid.innerHTML = TRACKS.map(tr => \`
626
+ <div class="card">
627
+ <span class="pill">\${tr.code} · \${tr.hours} ак. часов</span>
628
+ <h4 style="margin:4px 0 0; font-size:16px;">\${tr.title}</h4>
629
+ <div style="font-size:12px; color:#34d399;">Проходной балл: \${tr.passScore}/100 \${tr.requiredForFranchise ? '· Обязателен для франшизы' : '· Профильный'}</div>
630
+ <ul class="feature-ul">
631
+ \${tr.topics.map(tp => \`<li>\${tp}</li>\`).join('')}
632
+ </ul>
633
+ </div>
634
+ \`).join('');
635
+ }
636
+
637
+ const stGrid = document.getElementById('streamsGrid');
638
+ if (stGrid && FRANCHISE.partnerRevenueStreams) {
639
+ stGrid.innerHTML = FRANCHISE.partnerRevenueStreams.map(st => \`
640
+ <div class="card">
641
+ <span class="pill">\${st.share}</span>
642
+ <h4 style="margin:4px 0 0; font-size:16px;">\${st.title}</h4>
643
+ <p style="margin:0; color:var(--muted); font-size:13.5px;">\${st.description || st.share}</p>
644
+ <div style="margin-top:auto; padding-top:6px; font-size:13px; font-weight:700; color:#34d399;">Средний чек: ~\${fmtRub(st.recommendedAvgRub)}</div>
645
+ </div>
646
+ \`).join('');
647
+ }
648
+ }
649
+
650
+ function recalcFranchise() {
651
+ const code = document.getElementById('selRegion')?.value || 'RU-SVE';
652
+ const reg = REGIONS.find(r => r.code === code) || REGIONS[0];
653
+ const salesM = Math.max(0, Number(document.getElementById('inpPartnerSalesM')?.value || 0));
654
+ const salesRub = salesM * 1_000_000;
655
+
656
+ const feeRub = Math.round(FRANCHISE.baseFranchiseFeeRub * reg.economicIndex);
657
+ const quota = Math.max(1, Math.round(reg.populationMillions * FRANCHISE.partnersPerMillionPopulation));
658
+ const ratio = Math.min(1, salesRub / FRANCHISE.maxDiscountAnnualSalesRub);
659
+ const discountPct = Math.round((FRANCHISE.minDiscountPercent + ratio * (FRANCHISE.maxDiscountPercent - FRANCHISE.minDiscountPercent)) * 10) / 10;
660
+ const marginPerPak = Math.round(PRODUCT.basePriceRub * (discountPct / 100));
661
+
662
+ let extraPerClient = 0;
663
+ if (document.getElementById('chkAddNas')?.checked) extraPerClient += 350_000;
664
+ if (document.getElementById('chkAddInfer')?.checked) extraPerClient += 1_200_000;
665
+ if (document.getElementById('chkAddApi')?.checked) extraPerClient += 360_000;
666
+ if (document.getElementById('chkAddSup')?.checked) extraPerClient += 240_000;
667
+ if (document.getElementById('chkAddSkills')?.checked) extraPerClient += 600_000;
668
+
669
+ const totalPerClient = marginPerPak + extraPerClient;
670
+ const paksCount = Math.max(1, Math.round(salesRub / PRODUCT.basePriceRub));
671
+ const annualTotalIncome = Math.round(salesRub * (discountPct / 100)) + (paksCount * extraPerClient);
672
+
673
+ document.getElementById('lblFranchiseFee').textContent = fmtRub(feeRub);
674
+ document.getElementById('lblPartnerQuota').textContent = 'Квота региона по населению (' + reg.populationMillions + ' млн чел.): макс. ' + quota + ' партнёр(ов)';
675
+ document.getElementById('lblPartnerEcon').innerHTML = [
676
+ '• Экономический индекс региона (' + reg.name + ', ВРП ' + reg.grpTrillionRub + ' трлн ₽): <b>' + reg.economicIndex + '</b>',
677
+ '• Скидка партнёра (от 10% до 50%): <b>' + discountPct + '%</b> (при объёме ' + salesM + ' млн ₽/год)',
678
+ '• Доход партнёра с лицензии 1 ПАК: <b>' + fmtRub(marginPerPak) + '</b>',
679
+ '• Доп. доход партнёра на клиенте (NAS, инференс, API моделей, поддержка, обучение агентов/навыков): <b>+' + fmtRub(extraPerClient) + '</b> (100% партнёру)',
680
+ '• Совокупный доход партнёра с 1 внедрения: <b>' + fmtRub(totalPerClient) + '</b>',
681
+ '• Прогноз совокупного годового дохода партнёра: <b style="color:#34d399;">' + fmtRub(annualTotalIncome) + '</b>',
682
+ ].join('<br>');
683
+ }
684
+
685
+ async function partnerIssuePakAction() {
686
+ const msgEl = document.getElementById('partnerActionMsg');
687
+ if (!lastRegisteredPartnerId) {
688
+ await registerPartnerAction();
689
+ }
690
+ if (!lastRegisteredPartnerId) return;
691
+ try {
692
+ const res = await fetch('/api/partners/issue-license', {
693
+ method: 'POST',
694
+ headers: { 'Content-Type': 'application/json' },
695
+ body: JSON.stringify({
696
+ partnerId: lastRegisteredPartnerId,
697
+ customer: {
698
+ name: document.getElementById('inpOrg').value.trim() || 'ООО «Заказчик Регион»',
699
+ inn: document.getElementById('inpInn').value.trim() || '6601234567',
700
+ },
701
+ hardwareWarrantyYears: 1,
702
+ partnerAddons: {
703
+ nasBackup: document.getElementById('chkAddNas')?.checked,
704
+ inferenceHardware: document.getElementById('chkAddInfer')?.checked,
705
+ partnerModelApiUrl: document.getElementById('chkAddApi')?.checked ? 'https://ai.partner-node.ru/v1' : '',
706
+ techSupportContract: document.getElementById('chkAddSup')?.checked,
707
+ agentTrainingSkills: document.getElementById('chkAddSkills')?.checked,
708
+ },
709
+ }),
710
+ });
711
+ const data = await res.json();
712
+ if (!res.ok) throw new Error(data.error || 'Ошибка выдачи лицензии партнёром');
713
+ document.getElementById('inpLicId').value = data.license.licenseId;
714
+ msgEl.textContent = 'Партнёр установил ПАК, привязал HWID (' + data.license.hardware.hwid + '), предоставил 1 год гарантии на оборудование и выдал лицензию ' + data.license.licenseId + '! Новая скидка партнёра: ' + data.partner.discountPercent + '%';
715
+ refreshRegistry();
716
+ } catch (err) {
717
+ msgEl.textContent = 'Ошибка: ' + err.message;
718
+ }
719
+ }
720
+
721
+ async function registerPartnerAction() {
722
+ const msgEl = document.getElementById('partnerActionMsg');
723
+ try {
724
+ const res = await fetch('/api/partners/register', {
725
+ method: 'POST',
726
+ headers: { 'Content-Type': 'application/json' },
727
+ body: JSON.stringify({
728
+ companyName: document.getElementById('inpPartnerCompany').value.trim(),
729
+ regionCode: document.getElementById('selRegion').value,
730
+ annualSalesRub: Number(document.getElementById('inpPartnerSalesM').value || 0) * 1_000_000,
731
+ }),
732
+ });
733
+ const data = await res.json();
734
+ if (!res.ok) throw new Error(data.error || 'Ошибка регистрации партнёра');
735
+ lastRegisteredPartnerId = data.partnerId;
736
+ msgEl.textContent = 'Партнёр ' + data.partnerId + ' зарегистрирован в регионе ' + data.regionName + '! Скидка: ' + data.discountPercent + '%, свободно мест в квоте региона: ' + data.regionQuota.availablePartnerSlots + ' из ' + data.regionQuota.maxPartnersQuota;
737
+ refreshRegistry();
738
+ } catch (err) {
739
+ msgEl.textContent = 'Отказ: ' + err.message;
740
+ }
741
+ }
742
+
743
+ async function certifySpecialistAction() {
744
+ const msgEl = document.getElementById('partnerActionMsg');
745
+ if (!lastRegisteredPartnerId) {
746
+ await registerPartnerAction();
747
+ }
748
+ if (!lastRegisteredPartnerId) return;
749
+ try {
750
+ const res = await fetch('/api/partners/certify', {
751
+ method: 'POST',
752
+ headers: { 'Content-Type': 'application/json' },
753
+ body: JSON.stringify({
754
+ partnerId: lastRegisteredPartnerId,
755
+ specialistName: document.getElementById('inpSpecName').value.trim(),
756
+ trackId: 'pak-ai-engineer',
757
+ score: 95,
758
+ }),
759
+ });
760
+ const data = await res.json();
761
+ if (!res.ok) throw new Error(data.error || 'Ошибка сертификации');
762
+ msgEl.textContent = 'Выдан сертификат ' + data.certificate.certificateId + ' (' + data.certificate.trackTitle + ') специалисту ' + data.certificate.specialistName + '!';
763
+ refreshRegistry();
764
+ } catch (err) {
765
+ msgEl.textContent = 'Ошибка: ' + err.message;
766
+ }
767
+ }
768
+
769
+ function chooseTier(id) {
770
+ document.getElementById('selTier').value = id;
771
+ recalc();
772
+ document.getElementById('checkout').scrollIntoView({ behavior: 'smooth' });
773
+ }
774
+
775
+ function recalc() {
776
+ const includeBase = document.getElementById('chkBasePak').checked;
777
+ const tierId = document.getElementById('selTier').value;
778
+ const seats = Math.max(0, Number(document.getElementById('inpSeats').value || 0));
779
+ const years = Math.max(1, Number(document.getElementById('inpYears').value || 1));
780
+ const tier = TIERS.find(t => t.id === tierId) || TIERS[1];
781
+
782
+ const baseSum = includeBase ? PRODUCT.basePriceRub : 0;
783
+ const addonSum = seats * tier.pricePerUserYearRub * years;
784
+ const total = baseSum + addonSum;
785
+
786
+ document.getElementById('lblTotal').textContent = fmtRub(total);
787
+ const lines = [];
788
+ if (includeBase) {
789
+ lines.push('• Базовый ПАК WORK (AMD Ryzen 495 8085 192GB 4TB + 5 вечных мест + 1 год поддержки): ' + fmtRub(baseSum));
790
+ }
791
+ if (seats > 0) {
792
+ lines.push('• Доп. годовые лицензии (' + seats + ' мест × ' + years + ' г., уровень ' + tier.name + ', реакция ' + tier.reactionLabel + '): ' + fmtRub(addonSum));
793
+ }
794
+ lines.push('• Итого одновременных рабочих мест после оплаты: ' + (5 + seats) + ' (5 вечных + ' + seats + ' годовых)');
795
+ document.getElementById('lblBreakdown').innerHTML = lines.join('<br>');
796
+ }
797
+
798
+ async function submitOrder() {
799
+ const statusEl = document.getElementById('orderStatus');
800
+ statusEl.textContent = 'Создание счёта в ЮKassa…';
801
+ try {
802
+ const res = await fetch('/api/orders/checkout', {
803
+ method: 'POST',
804
+ headers: { 'Content-Type': 'application/json' },
805
+ body: JSON.stringify({
806
+ includeBasePak: document.getElementById('chkBasePak').checked,
807
+ tier: document.getElementById('selTier').value,
808
+ addonSeats: Number(document.getElementById('inpSeats').value || 0),
809
+ years: Number(document.getElementById('inpYears').value || 1),
810
+ licenseId: document.getElementById('inpLicId').value.trim(),
811
+ customer: {
812
+ name: document.getElementById('inpOrg').value.trim(),
813
+ inn: document.getElementById('inpInn').value.trim(),
814
+ email: document.getElementById('inpEmail').value.trim(),
815
+ },
816
+ }),
817
+ });
818
+ const data = await res.json();
819
+ if (!res.ok) throw new Error(data.error || 'Ошибка создания заказа');
820
+ document.getElementById('inpLicId').value = data.licenseId || '';
821
+ statusEl.innerHTML = 'Заказ <b>' + data.orderId + '</b> создан (лицензия <b>' + data.licenseId + '</b>). <a href="' + data.confirmationUrl + '" target="_blank" style="color:#34d399; font-weight:700; text-decoration:underline;">Оплатить в ЮKassa →</a>';
822
+ refreshRegistry();
823
+ } catch (err) {
824
+ statusEl.textContent = 'Ошибка: ' + err.message;
825
+ }
826
+ }
827
+
828
+ function setBotCmd(cmd) {
829
+ document.getElementById('botText').value = cmd;
830
+ sendBotSim();
831
+ }
832
+
833
+ async function sendBotSim() {
834
+ const channel = document.getElementById('botChannel').value;
835
+ const chatId = document.getElementById('botChatId').value.trim();
836
+ const text = document.getElementById('botText').value;
837
+ const box = document.getElementById('botReplyBox');
838
+ const res = await fetch('/api/bots/' + channel + '/webhook', {
839
+ method: 'POST',
840
+ headers: { 'Content-Type': 'application/json' },
841
+ body: JSON.stringify({ chat_id: chatId, text, user_name: 'Клиент ПАК' }),
842
+ });
843
+ const data = await res.json();
844
+ box.textContent = (data.reply || data.result?.reply || JSON.stringify(data, null, 2));
845
+ refreshRegistry();
846
+ }
847
+
848
+ async function createDemoPak() {
849
+ const res = await fetch('/api/license/issue', {
850
+ method: 'POST',
851
+ headers: { 'Content-Type': 'application/json' },
852
+ body: JSON.stringify({
853
+ customer: {
854
+ name: document.getElementById('inpOrg').value.trim() || 'ООО «Маслоторг»',
855
+ inn: document.getElementById('inpInn').value.trim() || '7701234567',
856
+ email: document.getElementById('inpEmail').value.trim() || 'admin@maslotorg.ru',
857
+ },
858
+ }),
859
+ });
860
+ const lic = await res.json();
861
+ if (lic.licenseId) {
862
+ document.getElementById('inpLicId').value = lic.licenseId;
863
+ document.getElementById('botText').value = '/bind ' + lic.licenseId;
864
+ }
865
+ refreshRegistry();
866
+ }
867
+
868
+ async function refreshRegistry() {
869
+ const res = await fetch('/api/admin/overview');
870
+ const data = await res.json();
871
+ const lines = [];
872
+ lines.push('=== Лицензии ПАК (' + (data.licenses?.length || 0) + ') ===');
873
+ for (const l of (data.licenses || [])) {
874
+ lines.push('• ' + l.licenseId + ' | ' + (l.customer?.name || '') + ' | Мест: ' + l.totalSeats + ' (5 вечных + ' + l.addonSeats + ' годовых) | SLA: ' + l.support.tierName + ' | Код бота: ' + l.support.bindCode);
875
+ }
876
+ lines.push('\\n=== Тикеты поддержки (' + (data.tickets?.length || 0) + ') ===');
877
+ for (const t of (data.tickets || [])) {
878
+ lines.push('• ' + t.ticketId + ' [' + t.channel.toUpperCase() + '] ' + t.tierName + ' (реакция ' + t.reactionLabel + '): ' + (t.messages?.[0]?.text || ''));
879
+ }
880
+ document.getElementById('registryBox').textContent = lines.join('\\n');
881
+ }
882
+
883
+ renderCatalog();
884
+ recalc();
885
+ recalcFranchise();
886
+ refreshRegistry();
887
+ </script>
888
+ </body>
889
+ </html>`;
890
+ }
891
+
892
+ /**
893
+ * Страница встроенной песочницы ЮKassa (/pay/sandbox/:orderId) для удобной демонстрации и тестирования оплаты.
894
+ */
895
+ export function renderSandboxPaymentPage(order) {
896
+ return /* html */`<!DOCTYPE html>
897
+ <html lang="ru">
898
+ <head>
899
+ <meta charset="utf-8">
900
+ <title>ЮKassa (Демо-шлюз) — Оплата заказа ${order.orderId}</title>
901
+ <style>
902
+ body {
903
+ margin: 0;
904
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
905
+ background: #f1f5f9;
906
+ color: #0f172a;
907
+ display: flex;
908
+ align-items: center;
909
+ justify-content: center;
910
+ min-height: 100vh;
911
+ padding: 20px;
912
+ }
913
+ .card {
914
+ background: #fff;
915
+ border-radius: 16px;
916
+ max-width: 480px;
917
+ width: 100%;
918
+ padding: 28px;
919
+ box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
920
+ }
921
+ .amount {
922
+ font-size: 32px;
923
+ font-weight: 800;
924
+ color: #2563eb;
925
+ margin: 12px 0;
926
+ }
927
+ .btn {
928
+ width: 100%;
929
+ padding: 14px;
930
+ border-radius: 10px;
931
+ border: none;
932
+ font-size: 16px;
933
+ font-weight: 700;
934
+ cursor: pointer;
935
+ margin-top: 12px;
936
+ }
937
+ .btn-pay { background: #2563eb; color: #fff; }
938
+ .btn-cancel { background: #e2e8f0; color: #334155; }
939
+ </style>
940
+ </head>
941
+ <body>
942
+ <div class="card">
943
+ <div style="font-size:12px; font-weight:700; color:#64748b; text-transform:uppercase;">ЮKassa · Платёжный шлюз work.odant.org</div>
944
+ <h2 style="margin:6px 0 0;">Заказ ${order.orderId}</h2>
945
+ <div class="amount">${Number(order.amountRub).toLocaleString('ru-RU')} ₽</div>
946
+ <div style="font-size:14px; color:#475569; margin-bottom:16px;">
947
+ <div><b>Лицензия ПАК:</b> ${order.licenseId}</div>
948
+ <div><b>Клиент:</b> ${order.customer?.name || '—'} (ИНН ${order.customer?.inn || '—'})</div>
949
+ <div><b>Тариф SLA:</b> ${order.tierName} (${order.addonSeats} доп. мест × ${order.years} г.)</div>
950
+ <div><b>Статус:</b> <span id="st">${order.status}</span></div>
951
+ </div>
952
+ <button class="btn btn-pay" onclick="confirmPay('payment.succeeded')">Подтвердить оплату (Webhook payment.succeeded)</button>
953
+ <button class="btn btn-cancel" onclick="confirmPay('payment.canceled')">Отменить платёж</button>
954
+ <div id="msg" style="margin-top:14px; font-size:14px; font-weight:600; color:#059669;"></div>
955
+ </div>
956
+ <script>
957
+ async function confirmPay(event) {
958
+ const res = await fetch('/api/payments/yookassa/webhook', {
959
+ method: 'POST',
960
+ headers: { 'Content-Type': 'application/json' },
961
+ body: JSON.stringify({
962
+ type: 'notification',
963
+ event,
964
+ object: {
965
+ id: '${order.paymentId}',
966
+ status: event === 'payment.succeeded' ? 'succeeded' : 'canceled',
967
+ amount: { value: '${Number(order.amountRub).toFixed(2)}', currency: 'RUB' },
968
+ metadata: { orderId: '${order.orderId}', licenseId: '${order.licenseId}' }
969
+ }
970
+ })
971
+ });
972
+ const data = await res.json();
973
+ document.getElementById('st').textContent = data.status || event;
974
+ document.getElementById('msg').textContent = data.status === 'paid'
975
+ ? 'Оплата прошла! Лицензия ' + data.licenseId + ' обновлена (всего мест: ' + data.totalSeats + ').'
976
+ : 'Платёж отменён.';
977
+ }
978
+ </script>
979
+ </body>
980
+ </html>`;
981
+ }