neuroalpha 0.2.0 → 0.3.12

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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -3
  3. package/bin/cli.js +69 -69
  4. package/config/seed-run.json +1 -0
  5. package/config/strategy.json +4 -10
  6. package/deploy/install.sh +128 -0
  7. package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +103 -0
  8. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -1
  9. package/docs//320/234/320/225/320/245/320/220/320/235/320/230/320/232/320/220_/320/230_/320/237/320/220/320/240/320/220/320/234/320/225/320/242/320/240/320/253.md +171 -0
  10. package/package.json +15 -5
  11. package/public/admin.html +426 -0
  12. package/public/app.js +741 -251
  13. package/public/index.html +45 -20
  14. package/public/landing.html +1004 -870
  15. package/public/legal.html +22 -0
  16. package/public/styles.css +49 -15
  17. package/server/api/routes.js +176 -23
  18. package/server/auth/index.js +544 -340
  19. package/server/billing/yookassa.js +1 -0
  20. package/server/brokers/index.js +1224 -645
  21. package/server/config.js +6 -4
  22. package/server/data/moex.js +1 -0
  23. package/server/db.js +53 -2
  24. package/server/hub.js +1 -0
  25. package/server/index.js +10 -2
  26. package/server/llm/client.js +1 -0
  27. package/server/llm/providers.js +50 -3
  28. package/server/model/dataset.js +362 -352
  29. package/server/model/forecast.js +15 -21
  30. package/server/model/linalg.js +1 -0
  31. package/server/model/live.js +489 -486
  32. package/server/model/metrics.js +1 -0
  33. package/server/model/models.js +31 -21
  34. package/server/model/profiles.js +1 -0
  35. package/server/model/research.js +119 -172
  36. package/server/model/vocab.js +1 -0
  37. package/server/news/index.js +1 -0
  38. package/server/notify/index.js +304 -168
  39. package/server/params-meta.js +20 -25
  40. package/server/settings.js +1 -0
  41. package/server/strategist/analytics.js +1 -0
  42. package/server/strategist/index.js +6 -6
  43. package/server/strategist/tools.js +2 -1
  44. package/server/util.js +1 -0
  45. package/server/model/gpu-worker.html +0 -11
  46. package/server/model/gpu-worker.js +0 -60
  47. package/server/model/tf-node.js +0 -26
  48. package/server/model/transformer-core.js +0 -330
  49. package/server/model/transformer-gpu.js +0 -221
  50. package/server/model/transformer-pool.js +0 -200
  51. package/server/model/transformer-worker.js +0 -28
  52. package/server/model/transformer.js +0 -57
@@ -0,0 +1,426 @@
1
+ <!-- Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License. -->
2
+ <!doctype html>
3
+ <html lang="ru">
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <title>Админ-консоль · НейроАльфа</title>
8
+ <style>
9
+ :root {
10
+ --bg: #0b0e14;
11
+ --panel: #131822;
12
+ --panel2: #18202f;
13
+ --line: #242c3d;
14
+ --text: #eef2f6;
15
+ --muted: #94a3b8;
16
+ --accent: #3b82f6;
17
+ --green: #16a34a;
18
+ --red: #ef4444;
19
+ --yellow: #eab308;
20
+ }
21
+ * { box-sizing: border-box; }
22
+ body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
23
+ a { color: var(--accent); text-decoration: none; }
24
+ code { background: rgba(255,255,255,0.06); padding: 2px 6px; border-radius: 4px; font-size: 12.5px; color: #93c5fd; }
25
+ .wrap { max-width: 1180px; margin: 0 auto; padding: 24px 24px 64px; }
26
+ .top { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; padding-bottom: 16px; margin-bottom: 22px; border-bottom: 1px solid var(--line); }
27
+ .brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; color: var(--text); }
28
+ .badge { background: rgba(59,130,246,0.16); color: #93c5fd; border: 1px solid rgba(59,130,246,0.35); border-radius: 6px; padding: 2px 8px; font-size: 12px; font-weight: 600; }
29
+ .tag { display: inline-block; padding: 2px 7px; border-radius: 5px; font-size: 12px; background: rgba(148,163,184,0.14); color: var(--muted); }
30
+ .tag.ok { background: rgba(22,163,74,0.16); color: #4ade80; }
31
+ .tag.mid { background: rgba(234,179,8,0.16); color: #facc15; }
32
+ .tag.no { background: rgba(239,68,68,0.16); color: #f87171; }
33
+ .pos { color: #4ade80; }
34
+ .neg { color: #f87171; }
35
+ .muted { color: var(--muted); }
36
+ .small { font-size: 12.5px; }
37
+ .kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
38
+ .kpi { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
39
+ .kpi .v { font-size: 22px; font-weight: 700; }
40
+ .kpi .l { font-size: 12.5px; color: var(--muted); margin-top: 2px; }
41
+ .tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 18px; border-bottom: 1px solid var(--line); padding-bottom: 10px; }
42
+ .tabs button { background: transparent; color: var(--muted); border: 1px solid transparent; padding: 8px 14px; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 13.5px; }
43
+ .tabs button:hover { color: var(--text); background: var(--panel); }
44
+ .tabs button.active { background: var(--accent); color: #fff; }
45
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 20px 22px; margin-bottom: 18px; }
46
+ .card h2 { margin: 0 0 6px; font-size: 17px; }
47
+ .card p.sub { margin: 0 0 16px; color: var(--muted); font-size: 13px; }
48
+ .row-gap { display: flex; gap: 10px; align-items: center; }
49
+ input, select { background: #0e131d; color: var(--text); border: 1px solid var(--line); border-radius: 7px; padding: 8px 11px; font: inherit; }
50
+ input:focus, select:focus { outline: none; border-color: var(--accent); }
51
+ input.secret-input { -webkit-text-security: disc; }
52
+ input.secret-input:focus { -webkit-text-security: none; }
53
+ button, .btn { background: var(--panel2); color: var(--text); border: 1px solid var(--line); border-radius: 7px; padding: 8px 14px; font: inherit; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
54
+ button:hover, .btn:hover { border-color: #3b4963; }
55
+ button.primary, .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
56
+ button:disabled { opacity: 0.45; cursor: default; }
57
+ table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
58
+ th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); }
59
+ th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; }
60
+ .num { text-align: right; }
61
+ .grid-form { display: grid; gap: 12px; }
62
+ .field-row { display: grid; grid-template-columns: 240px 1fr; gap: 12px; align-items: center; }
63
+ #toast { position: fixed; right: 20px; bottom: 20px; background: #1e293b; color: #fff; border: 1px solid var(--accent); padding: 10px 16px; border-radius: 8px; box-shadow: 0 10px 25px rgba(0,0,0,0.4); z-index: 999; display: none; }
64
+ @media (max-width: 780px) {
65
+ .kpis { grid-template-columns: 1fr 1fr; }
66
+ .field-row { grid-template-columns: 1fr; }
67
+ }
68
+ </style>
69
+ </head>
70
+ <body>
71
+ <div class="wrap">
72
+ <header class="top">
73
+ <div class="brand">
74
+ <span>◈ НейроАльфа</span>
75
+ <span class="badge">Консоль администратора платформы</span>
76
+ </div>
77
+ <div class="row-gap" style="flex-wrap:wrap">
78
+ <a class="btn" href="/landing">Витрина (/landing)</a>
79
+ <a class="btn" href="/?admin=1">Торговый терминал →</a>
80
+ </div>
81
+ </header>
82
+
83
+ <div class="kpis" id="adm-kpis">
84
+ <div class="kpi"><div class="v" id="kpi-users">—</div><div class="l">всего пользователей</div></div>
85
+ <div class="kpi"><div class="v pos" id="kpi-paid">—</div><div class="l">платных подписок (Инвестор / Профи)</div></div>
86
+ <div class="kpi"><div class="v" id="kpi-revenue">—</div><div class="l">оплачено через ЮKassa</div></div>
87
+ <div class="kpi"><div class="v" id="kpi-bots">—</div><div class="l">привязано Telegram / MAX</div></div>
88
+ </div>
89
+
90
+ <nav class="tabs" id="adm-tabs">
91
+ <button type="button" data-tab="users" class="active">👥 Пользователи и тарифы</button>
92
+ <button type="button" data-tab="billing">💳 ЮKassa, Платежи и Промокоды</button>
93
+ <button type="button" data-tab="oauth">🔑 OAuth-вход (Яндекс / Сбер / T-ID / VK)</button>
94
+ <button type="button" data-tab="bots">🤖 Боты платформы и Реквизиты оферты</button>
95
+ </nav>
96
+
97
+ <!-- Вкладка 1: Пользователи -->
98
+ <section data-pane="users">
99
+ <div class="card">
100
+ <div class="row-gap" style="justify-content:space-between;flex-wrap:wrap;margin-bottom:12px">
101
+ <div>
102
+ <h2>Пользователи платформы</h2>
103
+ <p class="sub" style="margin:0">Управление подписками, проверка привязки мессенджеров и API-ключей.</p>
104
+ </div>
105
+ <input id="usr-search" type="search" placeholder="Поиск по имени или email…" style="width:260px">
106
+ </div>
107
+ <div id="users-table-wrap">Загрузка…</div>
108
+ </div>
109
+ </section>
110
+
111
+ <!-- Вкладка 2: ЮKassa и Промокоды -->
112
+ <section data-pane="billing" hidden>
113
+ <div class="card">
114
+ <h2>Приём платежей ЮKassa (API v3)</h2>
115
+ <p class="sub">Webhook URL для личного кабинета ЮKassa: <code id="yoo-webhook"></code></p>
116
+ <form id="form-billing" class="grid-form">
117
+ <div class="field-row">
118
+ <span>Shop ID магазина ЮKassa</span>
119
+ <input name="shopId" placeholder="Например, 123456">
120
+ </div>
121
+ <div class="field-row">
122
+ <span>Секретный ключ (Secret Key)</span>
123
+ <input name="secretKey" type="text" class="secret-input" autocomplete="off" spellcheck="false" data-lpignore="true" placeholder="live_… или test_…">
124
+ </div>
125
+ <div class="field-row">
126
+ <span>Онлайн-чеки 54-ФЗ</span>
127
+ <label class="row-gap"><input type="checkbox" name="sendReceipt"> Отправлять структуру чека (receipt) в ЮKassa</label>
128
+ </div>
129
+ <div><button type="submit" class="primary">Сохранить настройки ЮKassa</button></div>
130
+ </form>
131
+ </div>
132
+
133
+ <div class="card">
134
+ <h2>Промокоды (выдача партнёрского и пробного доступа)</h2>
135
+ <p class="sub">Промокод активируется пользователем в личном кабинете или в настройках терминала.</p>
136
+ <form id="form-promo" class="row-gap" style="flex-wrap:wrap;margin-bottom:14px">
137
+ <input name="code" placeholder="Код (напр. ALPHA14)" required style="width:170px">
138
+ <select name="plan" style="width:180px">
139
+ <option value="investor">Тариф «Инвестор»</option>
140
+ <option value="pro">Тариф «Профи»</option>
141
+ </select>
142
+ <input name="days" type="number" value="14" min="1" max="365" style="width:110px" title="Длительность, дней" placeholder="Дней">
143
+ <input name="maxUses" type="number" value="50" min="1" max="10000" style="width:120px" title="Лимит активаций" placeholder="Лимит">
144
+ <button type="submit" class="primary">Создать промокод</button>
145
+ </form>
146
+ <div id="promos-list"></div>
147
+ </div>
148
+
149
+ <div class="card">
150
+ <h2>История платежей ЮKassa</h2>
151
+ <div id="payments-wrap"></div>
152
+ </div>
153
+ </section>
154
+
155
+ <!-- Вкладка 3: OAuth -->
156
+ <section data-pane="oauth" hidden>
157
+ <div class="card">
158
+ <h2>Провайдеры быстрого входа (OAuth 2.0)</h2>
159
+ <p class="sub">Настройка кнопок входа в 1 клик на витрине <code>/landing</code>. Скопируйте Callback URL в настройки соответствующего провайдера.</p>
160
+ <form id="form-oauth" class="grid-form">
161
+ <div id="oauth-rows" class="grid-form"></div>
162
+ <div><button type="submit" class="primary">Сохранить OAuth-провайдеров</button></div>
163
+ </form>
164
+ </div>
165
+ </section>
166
+
167
+ <!-- Вкладка 4: Боты и Реквизиты -->
168
+ <section data-pane="bots" hidden>
169
+ <div class="card">
170
+ <h2>Серверные боты уведомлений Telegram и MAX</h2>
171
+ <p class="sub">От имени этих ботов пользователям приходят карточки подтверждения брокерских заявок и коды быстрого входа.</p>
172
+ <form id="form-notify" class="grid-form">
173
+ <div class="field-row">
174
+ <span>Имя бота Telegram (без @)</span>
175
+ <input name="tgBotName" placeholder="neuroalpha_trade_bot">
176
+ </div>
177
+ <div class="field-row">
178
+ <span>Токен Telegram Bot API</span>
179
+ <input name="tgToken" type="text" class="secret-input" autocomplete="off" spellcheck="false" data-lpignore="true" placeholder="Токен от @BotFather">
180
+ </div>
181
+ <div class="field-row">
182
+ <span>Имя бота в мессенджере MAX</span>
183
+ <input name="maxBotName" placeholder="neuroalpha_bot">
184
+ </div>
185
+ <div class="field-row">
186
+ <span>Токен MAX Bot API</span>
187
+ <input name="maxToken" type="text" class="secret-input" autocomplete="off" spellcheck="false" data-lpignore="true" placeholder="Токен бота MAX">
188
+ </div>
189
+ <div><button type="submit" class="primary">Сохранить настройки ботов</button></div>
190
+ </form>
191
+ </div>
192
+
193
+ <div class="card">
194
+ <h2>Юридические реквизиты оферты (для модерации ЮKassa и <code>/legal</code>)</h2>
195
+ <p class="sub">Автоматически подставляются в Публичную оферту и подвал лендинга.</p>
196
+ <form id="form-legal" class="grid-form">
197
+ <div class="field-row"><span>ФИО / Наименование</span><input name="ownerName" placeholder="ИП Иванов И.И."></div>
198
+ <div class="field-row"><span>Правовой статус</span><input name="status" placeholder="Индивидуальный предприниматель / Плательщик НПД"></div>
199
+ <div class="field-row"><span>ИНН</span><input name="inn" placeholder="10 или 12 цифр"></div>
200
+ <div class="field-row"><span>ОГРНИП / ОГРН</span><input name="ogrn" placeholder="Необязательно для самозанятых"></div>
201
+ <div class="field-row"><span>Email поддержки</span><input name="email" placeholder="support@neuroalpha.ru"></div>
202
+ <div class="field-row"><span>Телефон / Контакт</span><input name="phone" placeholder="+7 …"></div>
203
+ <div class="field-row"><span>Адрес / Регион</span><input name="address" placeholder="Российская Федерация"></div>
204
+ <div><button type="submit" class="primary">Сохранить реквизиты</button></div>
205
+ </form>
206
+ </div>
207
+ </section>
208
+ </div>
209
+
210
+ <div id="toast"></div>
211
+
212
+ <script>
213
+ const $ = (s) => document.querySelector(s);
214
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
215
+ const fmtNum = (n) => Number(n || 0).toLocaleString('ru-RU');
216
+ const fmtTs = (ts) => ts ? new Date(ts).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—';
217
+
218
+ function toast(msg) {
219
+ const el = $('#toast');
220
+ el.textContent = msg;
221
+ el.style.display = 'block';
222
+ clearTimeout(toast._t);
223
+ toast._t = setTimeout(() => { el.style.display = 'none'; }, 3000);
224
+ }
225
+
226
+ async function api(url, opts = {}) {
227
+ const res = await fetch(url, {
228
+ method: opts.method || 'GET',
229
+ headers: { 'Content-Type': 'application/json' },
230
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
231
+ });
232
+ const j = await res.json().catch(() => ({}));
233
+ if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
234
+ return j;
235
+ }
236
+
237
+ let admState = null;
238
+
239
+ $('#adm-tabs').addEventListener('click', (e) => {
240
+ const b = e.target.closest('button[data-tab]');
241
+ if (!b) return;
242
+ const tab = b.dataset.tab;
243
+ document.querySelectorAll('#adm-tabs button').forEach((x) => x.classList.toggle('active', x === b));
244
+ document.querySelectorAll('section[data-pane]').forEach((p) => { p.hidden = p.dataset.pane !== tab; });
245
+ });
246
+
247
+ function renderUsers() {
248
+ const q = ($('#usr-search').value || '').trim().toLowerCase();
249
+ const list = (admState?.users || []).filter((u) => !q || String(u.name || '').toLowerCase().includes(q) || String(u.email || '').toLowerCase().includes(q));
250
+ if (!list.length) {
251
+ $('#users-table-wrap').innerHTML = '<p class="muted small">Пользователи не найдены.</p>';
252
+ return;
253
+ }
254
+ $('#users-table-wrap').innerHTML = `<table>
255
+ <thead><tr><th>ID</th><th>Имя и Email</th><th>Роль</th><th>Тариф</th><th>Оплачен до</th><th>Мессенджеры</th><th>Сменить тариф (на 30 дн.)</th></tr></thead>
256
+ <tbody>${list.map((u) => `<tr>
257
+ <td>${u.id}</td>
258
+ <td><b>${esc(u.name)}</b><div class="muted small">${esc(u.email)}</div></td>
259
+ <td><span class="tag ${u.role === 'admin' ? 'mid' : ''}">${esc(u.role)}</span></td>
260
+ <td><span class="tag ${u.plan !== 'free' ? 'ok' : ''}">${esc(u.planName)}</span></td>
261
+ <td class="muted small">${u.planUntil > Date.now() ? fmtTs(u.planUntil) : 'бессрочно'}</td>
262
+ <td class="small">${u.tgConnected ? '<span class="pos">TG ✓</span> ' : ''}${u.maxConnected ? '<span class="pos">MAX ✓</span>' : (!u.tgConnected ? '<span class="muted">—</span>' : '')}</td>
263
+ <td>
264
+ <select data-user-plan="${u.id}">
265
+ <option value="free"${u.plan === 'free' ? ' selected' : ''}>Старт (Free)</option>
266
+ <option value="investor"${u.plan === 'investor' ? ' selected' : ''}>Инвестор (3 900 ₽)</option>
267
+ <option value="pro"${u.plan === 'pro' ? ' selected' : ''}>Профи (9 900 ₽)</option>
268
+ </select>
269
+ </td>
270
+ </tr>`).join('')}</tbody>
271
+ </table>`;
272
+ }
273
+
274
+ $('#usr-search').addEventListener('input', renderUsers);
275
+ $('#users-table-wrap').addEventListener('change', async (e) => {
276
+ const sel = e.target.closest('select[data-user-plan]');
277
+ if (!sel) return;
278
+ try {
279
+ const r = await api('/api/admin/user-plan', { method: 'POST', body: { userId: Number(sel.dataset.userPlan), plan: sel.value, days: 30 } });
280
+ admState.users = r.users || admState.users;
281
+ updateKpis();
282
+ renderUsers();
283
+ toast('Тариф пользователя обновлён');
284
+ } catch (err) { toast(err.message); }
285
+ });
286
+
287
+ function updateKpis() {
288
+ const users = admState?.users || [];
289
+ const payments = admState?.payments || [];
290
+ $('#kpi-users').textContent = fmtNum(users.length);
291
+ $('#kpi-paid').textContent = fmtNum(users.filter((u) => u.plan === 'investor' || u.plan === 'pro').length);
292
+ const rev = payments.filter((p) => p.status === 'succeeded').reduce((s, p) => s + (Number(p.amount_rub) || 0), 0);
293
+ $('#kpi-revenue').textContent = `${fmtNum(rev)} ₽`;
294
+ $('#kpi-bots').textContent = fmtNum(users.filter((u) => u.tgConnected || u.maxConnected).length);
295
+ }
296
+
297
+ async function loadAdminState() {
298
+ admState = await api('/api/admin/state');
299
+ updateKpis();
300
+ renderUsers();
301
+
302
+ // Billing
303
+ $('#yoo-webhook').textContent = `${location.origin}/api/billing/yookassa/webhook`;
304
+ const bf = $('#form-billing').elements;
305
+ bf.shopId.value = admState.billing?.shopId || '';
306
+ bf.secretKey.value = '';
307
+ bf.secretKey.placeholder = admState.billing?.hasSecret ? 'Секретный ключ задан (оставьте пустым, чтобы не менять)' : 'Секретный ключ ЮKassa';
308
+ bf.sendReceipt.checked = Boolean(admState.billing?.sendReceipt);
309
+
310
+ // Promos
311
+ const promos = admState.promos || [];
312
+ $('#promos-list').innerHTML = promos.length
313
+ ? `<table><thead><tr><th>Код</th><th>Тариф</th><th>Дней</th><th>Использовано</th><th>Создан</th></tr></thead><tbody>${promos.map((p) => `<tr><td><code>${esc(p.code)}</code></td><td>${esc(p.plan)}</td><td>${p.days} дн.</td><td>${p.used_count} / ${p.max_uses}</td><td class="muted small">${fmtTs(p.created_at)}</td></tr>`).join('')}</tbody></table>`
314
+ : '<p class="muted small">Активных промокодов пока нет.</p>';
315
+
316
+ // Payments
317
+ const pays = admState.payments || [];
318
+ $('#payments-wrap').innerHTML = pays.length
319
+ ? `<table><thead><tr><th>Дата</th><th>ID платежа</th><th>Пользователь</th><th>Тариф</th><th class="num">Сумма</th><th>Статус</th></tr></thead><tbody>${pays.map((p) => `<tr><td class="muted small">${fmtTs(p.created_at)}</td><td><code>${esc(p.id)}</code></td><td>#${p.user_id}</td><td>${esc(p.plan)} (${p.months} мес.)</td><td class="num"><b>${fmtNum(p.amount_rub)} ₽</b></td><td><span class="tag ${p.status === 'succeeded' ? 'ok' : p.status === 'pending' ? 'mid' : 'no'}">${esc(p.status)}</span></td></tr>`).join('')}</tbody></table>`
320
+ : '<p class="muted small">Платежей через ЮKassa пока не было.</p>';
321
+
322
+ // OAuth
323
+ const oa = admState.oauth || {};
324
+ const provs = [
325
+ ['yandex', 'Яндекс ID (oauth.yandex.ru)'],
326
+ ['sber', 'Сбер ID (developers.sber.ru)'],
327
+ ['tbank', 'T-ID Т-Банк (tbank.ru)'],
328
+ ['google', 'Google OAuth (console.cloud.google.com)'],
329
+ ['vk', 'VK ID (id.vk.com)'],
330
+ ];
331
+ $('#oauth-rows').innerHTML = provs.map(([pid, lbl]) => {
332
+ const cur = oa[pid] || {};
333
+ return `<div style="display:grid;grid-template-columns:220px 200px 1fr auto;gap:10px;align-items:center">
334
+ <b>${esc(lbl)}</b>
335
+ <input name="${pid}_id" value="${esc(cur.clientId || '')}" placeholder="Client ID">
336
+ <input name="${pid}_secret" type="text" class="secret-input" autocomplete="off" spellcheck="false" data-lpignore="true" placeholder="${cur.hasSecret ? 'Client Secret задан (оставьте пустым)' : 'Client Secret'}">
337
+ <code>${location.origin}/api/auth/oauth/${pid}/callback</code>
338
+ </div>`;
339
+ }).join('');
340
+
341
+ // Bots
342
+ const nf = $('#form-notify').elements;
343
+ nf.tgBotName.value = admState.notify?.tgBotName || '';
344
+ nf.tgToken.value = '';
345
+ nf.tgToken.placeholder = admState.notify?.hasTgToken ? 'Токен Telegram задан (оставьте пустым)' : 'Токен Telegram Bot API';
346
+ nf.maxBotName.value = admState.notify?.maxBotName || '';
347
+ nf.maxToken.value = '';
348
+ nf.maxToken.placeholder = admState.notify?.hasMaxToken ? 'Токен MAX задан (оставьте пустым)' : 'Токен MAX Bot API';
349
+
350
+ // Legal
351
+ const lf = $('#form-legal').elements;
352
+ const lg = admState.legal || {};
353
+ for (const k of ['ownerName', 'status', 'inn', 'ogrn', 'email', 'phone', 'address']) {
354
+ if (lf[k]) lf[k].value = lg[k] || '';
355
+ }
356
+ }
357
+
358
+ $('#form-billing').addEventListener('submit', async (e) => {
359
+ e.preventDefault();
360
+ const f = e.target.elements;
361
+ try {
362
+ await api('/api/admin/billing', { method: 'POST', body: { shopId: f.shopId.value, secretKey: f.secretKey.value, sendReceipt: f.sendReceipt.checked } });
363
+ toast('Настройки ЮKassa сохранены');
364
+ loadAdminState();
365
+ } catch (err) { toast(err.message); }
366
+ });
367
+
368
+ $('#form-promo').addEventListener('submit', async (e) => {
369
+ e.preventDefault();
370
+ const f = e.target.elements;
371
+ try {
372
+ await api('/api/admin/promo', { method: 'POST', body: { code: f.code.value, plan: f.plan.value, days: f.days.value, maxUses: f.maxUses.value } });
373
+ f.code.value = '';
374
+ toast('Промокод создан');
375
+ loadAdminState();
376
+ } catch (err) { toast(err.message); }
377
+ });
378
+
379
+ $('#form-oauth').addEventListener('submit', async (e) => {
380
+ e.preventDefault();
381
+ const f = e.target.elements;
382
+ const body = {};
383
+ for (const pid of ['yandex', 'sber', 'tbank', 'google', 'vk']) {
384
+ body[pid] = { clientId: f[`${pid}_id`]?.value || '', clientSecret: f[`${pid}_secret`]?.value || '' };
385
+ }
386
+ try {
387
+ await api('/api/admin/oauth', { method: 'POST', body });
388
+ toast('Настройки OAuth-провайдеров сохранены');
389
+ loadAdminState();
390
+ } catch (err) { toast(err.message); }
391
+ });
392
+
393
+ $('#form-notify').addEventListener('submit', async (e) => {
394
+ e.preventDefault();
395
+ const f = e.target.elements;
396
+ try {
397
+ await api('/api/admin/notify', { method: 'POST', body: { tgBotName: f.tgBotName.value, tgToken: f.tgToken.value, maxBotName: f.maxBotName.value, maxToken: f.maxToken.value } });
398
+ toast('Настройки ботов сохранены');
399
+ loadAdminState();
400
+ } catch (err) { toast(err.message); }
401
+ });
402
+
403
+ $('#form-legal').addEventListener('submit', async (e) => {
404
+ e.preventDefault();
405
+ const f = e.target.elements;
406
+ try {
407
+ await api('/api/admin/legal', {
408
+ method: 'POST',
409
+ body: {
410
+ ownerName: f.ownerName.value,
411
+ status: f.status.value,
412
+ inn: f.inn.value,
413
+ ogrn: f.ogrn.value,
414
+ email: f.email.value,
415
+ phone: f.phone.value,
416
+ address: f.address.value,
417
+ },
418
+ });
419
+ toast('Юридические реквизиты оферты сохранены');
420
+ } catch (err) { toast(err.message); }
421
+ });
422
+
423
+ loadAdminState().catch((err) => toast(err.message));
424
+ </script>
425
+ </body>
426
+ </html>