neuroalpha 0.5.16 → 0.5.19

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.
@@ -7,6 +7,7 @@
7
7
  <title>НейроАльфа — ИИ-автопилот для рынка акций</title>
8
8
  <style>
9
9
  :root {
10
+ color-scheme: dark;
10
11
  --bg: #060911;
11
12
  --bg2: #0b101d;
12
13
  --panel: rgba(17, 24, 39, 0.72);
@@ -30,7 +31,7 @@
30
31
  font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
31
32
  overflow-x: hidden;
32
33
  }
33
- a { color: inherit; text-decoration: none; }
34
+ a, a:visited { color: inherit; text-decoration: none; }
34
35
  .wrap { max-width: 1200px; margin: 0 auto; padding: 0 24px; position: relative; z-index: 2; }
35
36
 
36
37
  /* ---------- Фоновое свечение и сетка ---------- */
@@ -332,10 +333,13 @@
332
333
  <a href="#paas-sec">Как работает PaaS</a>
333
334
  <a href="#brokers">Брокеры и мессенджеры</a>
334
335
  <a href="#pricing">Тарифы</a>
336
+ <a href="/splat3d.html">3D-демо</a>
335
337
  </nav>
336
- <div style="display:flex;gap:10px" id="nav-auth">
337
- <button class="btn" id="btn-login-open">Войти</button>
338
- <a class="btn primary" href="#pricing" id="btn-nav-cta">Тарифы и подключение →</a>
338
+ <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap" id="nav-auth">
339
+ <button class="btn" id="btn-login-open" type="button">Войти</button>
340
+ <button class="btn primary" id="btn-reg-open" type="button">Регистрация</button>
341
+ <a class="btn" href="#pricing" id="btn-nav-cta">Тарифы →</a>
342
+ <button class="btn" id="btn-nav-logout" type="button" hidden style="color:#f87171;border-color:rgba(248,113,113,0.35)">Выйти</button>
339
343
  </div>
340
344
  </div>
341
345
  </header>
@@ -599,7 +603,12 @@
599
603
 
600
604
  <div class="modal" id="auth-modal" hidden>
601
605
  <div class="modal-box">
602
- <h3 id="auth-title">Вход в НейроАльфа</h3>
606
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;background:#070b14;padding:4px;border-radius:10px;border:1px solid var(--line);margin-bottom:14px">
607
+ <button type="button" class="btn" id="tab-auth-reg" style="padding:8px 10px;font-size:13px;justify-content:center;border:0">Регистрация</button>
608
+ <button type="button" class="btn" id="tab-auth-login" style="padding:8px 10px;font-size:13px;justify-content:center;border:0">Вход в аккаунт</button>
609
+ </div>
610
+ <div id="auth-plan-banner" hidden style="margin-bottom:12px;padding:10px 12px;border-radius:10px;background:rgba(59,130,246,0.14);border:1px solid rgba(96,165,250,0.4);font-size:13px;color:#bfdbfe"></div>
611
+ <h3 id="auth-title" style="margin-top:0">Вход в НейроАльфа</h3>
603
612
  <div id="tg-bot-login-box" hidden style="margin-bottom:8px">
604
613
  <button type="button" class="oauth-btn full" id="btn-tg-bot-login" style="width:100%;background:rgba(36, 161, 222, 0.16);border-color:rgba(36, 161, 222, 0.45);color:#7dd3fc">
605
614
  <span class="oauth-ico" style="background:#24a1de;color:#fff">✈</span>
@@ -636,10 +645,10 @@
636
645
  </div>
637
646
  <div class="or-sep" id="oauth-or-sep" hidden>или по Email и паролю</div>
638
647
  <form id="auth-form">
639
- <input name="name" id="auth-name" placeholder="Ваше имя" hidden>
640
- <input name="email" type="email" required placeholder="Email">
648
+ <input name="name" id="auth-name" placeholder="Ваше имя (например, Иван)" hidden>
649
+ <input name="email" type="email" required placeholder="Ваш Email">
641
650
  <input name="password" type="password" required minlength="6" placeholder="Пароль (не короче 6 символов)">
642
- <label style="display:flex;gap:8px;align-items:flex-start;font-size:12.5px;color:var(--muted)"><input type="checkbox" required checked style="width:16px;height:16px;margin-top:2px"><span>Принимаю условия <a href="/legal#offer" target="_blank" style="color:#93c5fd">Публичной оферты</a>, <a href="/legal#privacy" target="_blank" style="color:#93c5fd">Политики конфиденциальности</a> и <a href="/legal#risks" target="_blank" style="color:#93c5fd">Декларации о рисках</a></span></label>
651
+ <label id="auth-offer-row" style="display:flex;gap:8px;align-items:flex-start;font-size:12.5px;color:var(--muted)"><input type="checkbox" id="auth-offer-chk" required checked style="width:16px;height:16px;margin-top:2px"><span>Принимаю условия <a href="/legal#offer" target="_blank" style="color:#93c5fd">Публичной оферты</a>, <a href="/legal#privacy" target="_blank" style="color:#93c5fd">Политики конфиденциальности</a> и <a href="/legal#risks" target="_blank" style="color:#93c5fd">Декларации о рисках</a></span></label>
643
652
  <div class="err" id="auth-err"></div>
644
653
  <button class="btn primary" type="submit" id="auth-submit">Войти</button>
645
654
  <button class="btn" type="button" id="auth-toggle">Нет аккаунта? Зарегистрироваться</button>
@@ -652,6 +661,7 @@
652
661
  <div class="modal" id="offer-modal" hidden>
653
662
  <div class="modal-box" style="max-width:660px">
654
663
  <h3>Договор публичной оферты и Декларация о рисках</h3>
664
+ <div id="offer-account-banner" style="margin-bottom:12px"></div>
655
665
  <div style="background:#070b14;border:1px solid var(--line);border-radius:10px;padding:14px 16px;max-height:260px;overflow-y:auto;font-size:13px;line-height:1.55;color:#cbd5e1;margin-bottom:14px">
656
666
  <p style="margin-top:0"><b>1. Предмет договора:</b> Настоящая Публичная оферта регулирует предоставление доступа к облачной аналитической платформе «НейроАльфа» (PaaS/SaaS) согласно выбранному тарифному плану («Инвестор», «Профи», «VIP»).</p>
657
667
  <p><b>2. Правовой статус (отсутствие ИИР и ДУ):</b> Сервис является исключительно программно-техническим средством (калькулятором статистических закономерностей). Исполнитель <b>не осуществляет инвестиционное консультирование</b>, не даёт индивидуальных инвестиционных рекомендаций (ФЗ № 39-ФЗ) и не осуществляет доверительное управление. Все сделки совершаются Пользователем самостоятельно на его личном брокерском счёте.</p>
@@ -808,56 +818,168 @@
808
818
  </svg>`;
809
819
  }
810
820
 
821
+ let lastMeMeta = {};
822
+ let paymentSuccessBanner = '';
823
+ const PLAN_TITLES = { investor: 'Инвестор (3 900 ₽/мес)', pro: 'Профи (9 900 ₽/мес)', vip: 'VIP', owner: 'Хозяин', none: 'Без подписки' };
824
+ const escH = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
825
+
826
+ function syncNavAuth() {
827
+ const loginBtn = document.getElementById('btn-login-open');
828
+ const regBtn = document.getElementById('btn-reg-open');
829
+ const logoutBtn = document.getElementById('btn-nav-logout');
830
+ const navCta = document.getElementById('btn-nav-cta');
831
+ if (currentUser) {
832
+ if (loginBtn) loginBtn.textContent = `👤 ${currentUser.name} · ${currentUser.planName}`;
833
+ if (regBtn) regBtn.hidden = true;
834
+ if (logoutBtn) logoutBtn.hidden = false;
835
+ if (navCta) {
836
+ if (currentUser.role === 'admin') {
837
+ navCta.href = '/admin';
838
+ navCta.textContent = 'Консоль админа →';
839
+ navCta.hidden = false;
840
+ } else {
841
+ navCta.href = '#cabinet-box';
842
+ navCta.textContent = 'Личный кабинет →';
843
+ navCta.hidden = false;
844
+ }
845
+ }
846
+ } else {
847
+ if (loginBtn) loginBtn.textContent = 'Войти';
848
+ if (regBtn) regBtn.hidden = false;
849
+ if (logoutBtn) logoutBtn.hidden = true;
850
+ if (navCta) {
851
+ navCta.href = '#pricing';
852
+ navCta.textContent = 'Тарифы →';
853
+ navCta.hidden = false;
854
+ }
855
+ }
856
+ }
857
+
858
+ async function doLogout(reopenRegWithPlan = null) {
859
+ await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
860
+ currentUser = null;
861
+ paymentSuccessBanner = '';
862
+ syncNavAuth();
863
+ renderCabinet();
864
+ if (reopenRegWithPlan) {
865
+ pendingPlan = reopenRegWithPlan;
866
+ openAuth(true);
867
+ }
868
+ }
869
+
811
870
  function renderCabinet() {
812
871
  const cab = document.getElementById('cabinet-box');
872
+ syncNavAuth();
813
873
  if (!cab) return;
814
874
  if (!currentUser) {
815
875
  cab.hidden = true;
816
876
  return;
817
877
  }
818
878
  cab.hidden = false;
819
- const escH = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
820
- // Терминал клиента работает на его машине (облако naXXXXXX.odant.org или свой компьютер) — см. блок «Где работать»
821
- const navCta = document.getElementById('btn-nav-cta');
822
- if (navCta) {
823
- if (currentUser.role === 'admin') {
824
- navCta.href = '/admin';
825
- navCta.textContent = 'Панель админа →';
826
- navCta.hidden = false;
827
- } else {
828
- navCta.hidden = true;
829
- }
830
- }
831
879
  const untilStr = currentUser.planUntil > Date.now()
832
880
  ? `оплачен до ${new Date(currentUser.planUntil).toLocaleDateString('ru-RU')}`
833
- : 'бессрочно';
881
+ : (currentUser.plan && currentUser.plan !== 'none' ? 'бессрочно' : 'подписка не активна');
834
882
  const capLim = currentUser.limits?.maxCapitalPerAccountRub
835
883
  ? (currentUser.limits.maxCapitalPerAccountRub >= 100000000 ? 'без ограничений' : `до ${(currentUser.limits.maxCapitalPerAccountRub / 1000000).toLocaleString('ru-RU')} млн ₽/счёт`)
836
- : 'демо-режим';
884
+ : 'требуется выбор тарифа';
885
+ const offerStr = currentUser.offerAcceptedAt
886
+ ? `<span class="pos">подписана ✓ (${new Date(currentUser.offerAcceptedAt).toLocaleDateString('ru-RU')})</span>`
887
+ : '<span style="color:var(--muted)">подписывается при оформлении тарифа</span>';
888
+ const nt = lastMeMeta.notify || {};
889
+ const code = currentUser.notifyCode || '';
890
+ const tgUrl = nt.tgBotName && code ? `https://t.me/${encodeURIComponent(nt.tgBotName)}?start=${encodeURIComponent(code)}` : '';
891
+ const maxUrl = nt.maxBotName && code ? `https://max.ru/${encodeURIComponent(nt.maxBotName)}?start=${encodeURIComponent(code)}` : '';
892
+ const payments = Array.isArray(currentUser.payments) ? currentUser.payments : [];
893
+ const payRowsHtml = payments.length
894
+ ? `<table style="width:100%;border-collapse:collapse;font-size:13px;margin-top:8px">
895
+ <thead><tr style="border-bottom:1px solid var(--line);color:var(--muted);text-align:left">
896
+ <th style="padding:6px 8px">Дата</th><th style="padding:6px 8px">Тариф</th><th style="padding:6px 8px">Период</th><th style="padding:6px 8px">Сумма</th><th style="padding:6px 8px">Статус</th>
897
+ </tr></thead>
898
+ <tbody>${payments.map((p) => {
899
+ const dt = p.created_at ? new Date(p.created_at).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—';
900
+ const stBadge = p.status === 'succeeded'
901
+ ? '<span class="pos" style="font-weight:700">Оплачен ✓</span>'
902
+ : p.status === 'canceled'
903
+ ? '<span class="neg">Отменён</span>'
904
+ : `<span style="color:#fbbf24">Ожидает подтверждения</span> <button type="button" class="btn" data-verify-pid="${escH(p.id)}" style="padding:2px 8px;font-size:11.5px;margin-left:6px">Проверить</button>`;
905
+ return `<tr style="border-bottom:1px solid rgba(148,163,184,0.1)">
906
+ <td style="padding:6px 8px;color:var(--muted)">${escH(dt)}</td>
907
+ <td style="padding:6px 8px"><b>${escH(PLAN_TITLES[p.plan] || p.plan)}</b></td>
908
+ <td style="padding:6px 8px">${Number(p.months || 1)} мес.</td>
909
+ <td style="padding:6px 8px"><b>${Number(p.amount_rub || 0).toLocaleString('ru-RU')} ₽</b></td>
910
+ <td style="padding:6px 8px">${stBadge}</td>
911
+ </tr>`;
912
+ }).join('')}</tbody>
913
+ </table>`
914
+ : '<div style="font-size:13px;color:var(--muted);margin-top:6px">Платежей по этому аккаунту пока не было. Выберите тариф ниже или активируйте промокод.</div>';
915
+
837
916
  cab.innerHTML = `<div class="cab-card">
917
+ ${paymentSuccessBanner ? `<div style="margin-bottom:16px;padding:12px 16px;border-radius:12px;background:rgba(16,185,129,0.16);border:1px solid rgba(16,185,129,0.45);color:#a7f3d0;font-size:14px;display:flex;justify-content:space-between;align-items:center;gap:12px"><span>${escH(paymentSuccessBanner)}</span><button type="button" class="btn" id="btn-close-pay-banner" style="padding:4px 10px;font-size:12px">ОК</button></div>` : ''}
838
918
  <div class="cab-grid">
839
919
  <div>
840
- <div class="sec-tag" style="margin-bottom:4px">Личный кабинет PaaS · Аккаунт активен</div>
920
+ <div class="sec-tag" style="margin-bottom:4px">Личный кабинет клиента · ${currentUser.role === 'admin' ? 'Администратор платформы' : 'Аккаунт активен'}</div>
841
921
  <h3 style="margin:0 0 8px;font-size:22px">${escH(currentUser.name)} <span style="color:var(--muted);font-weight:400;font-size:15px">(${escH(currentUser.email)})</span></h3>
842
- <div style="margin-bottom:14px;font-size:14.5px;color:#cbd5e1">
843
- Тариф: <b class="pos">${currentUser.planName}</b> (${untilStr}) · Брокерских счетов: до <b>${currentUser.limits?.maxBrokerAccounts ?? 0}</b> (${capLim}) · Режим: <b>${currentUser.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)'}</b>.
844
- </div>
845
- <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
846
- <input id="cab-promo-in" placeholder="Промокод или VIP-токен" style="padding:8px 12px;border-radius:9px;border:1px solid var(--line);background:var(--bg);color:var(--text);font-size:13.5px;width:220px">
847
- <button type="button" class="btn" id="cab-promo-btn" style="padding:8px 14px;font-size:13px">Активировать код</button>
922
+ <div style="margin-bottom:10px;font-size:14.5px;color:#cbd5e1;line-height:1.55">
923
+ Тариф: <b class="${currentUser.plan === 'none' ? '' : 'pos'}">${escH(currentUser.planName)}</b> (${escH(untilStr)}) · Брокерских счетов: до <b>${currentUser.limits?.maxBrokerAccounts ?? 0}</b> (${escH(capLim)}) · Режим: <b>${currentUser.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)'}</b><br>
924
+ Договор-оферта: ${offerStr}
848
925
  </div>
849
926
  </div>
850
- <div style="display:flex;flex-direction:column;gap:10px">
851
- <div style="display:flex;gap:10px;flex-wrap:wrap">
852
- <a class="btn" href="#pricing" style="flex:1">Сменить / продлить тариф</a>
853
- ${currentUser.role === 'admin' ? '<a class="btn" href="/admin">Консоль админа →</a>' : ''}
927
+ <div style="display:flex;flex-direction:column;gap:8px;min-width:240px">
928
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
929
+ <a class="btn primary" href="#pricing" style="flex:1;justify-content:center">${currentUser.plan === 'none' ? 'Выбрать и оплатить тариф' : 'Продлить / Сменить тариф'}</a>
930
+ <button type="button" class="btn" id="cab-verify-btn" title="Проверить статус последнего платежа в ЮKassa">Проверить оплату</button>
931
+ </div>
932
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
933
+ ${currentUser.role === 'admin' ? '<a class="btn" href="/admin" style="flex:1;justify-content:center">Консоль админа (/admin)</a><a class="btn" href="/?terminal=1" style="flex:1;justify-content:center">Терминал центра →</a>' : ''}
934
+ <button type="button" class="btn" id="cab-logout-btn" style="color:#f87171;border-color:rgba(248,113,113,0.35);justify-content:center">Выйти из аккаунта</button>
854
935
  </div>
855
936
  </div>
856
937
  </div>
938
+
857
939
  <div id="cab-workspace" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--line)"></div>
940
+
941
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px;margin-top:16px;padding-top:16px;border-top:1px solid var(--line)">
942
+ <div style="border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--bg)">
943
+ <div style="font-weight:700;margin-bottom:6px">📲 Уведомления и ИИ-Стратег в Telegram и MAX</div>
944
+ <div style="font-size:13px;color:#cbd5e1;line-height:1.5;margin-bottom:10px">
945
+ Ваш персональный код привязки: <b style="font-family:monospace;font-size:14px;color:#93c5fd">${escH(code || '—')}</b><br>
946
+ Статус: Telegram — ${currentUser.tgConnected ? '<b class="pos">привязан ✓</b>' : '<span style="color:var(--muted)">не привязан</span>'} · MAX — ${currentUser.maxConnected ? '<b class="pos">привязан ✓</b>' : '<span style="color:var(--muted)">не привязан</span>'}
947
+ </div>
948
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
949
+ ${tgUrl ? `<a class="btn" href="${escH(tgUrl)}" target="_blank" rel="noopener" style="padding:7px 12px;font-size:12.5px">Подключить Telegram</a>` : ''}
950
+ ${maxUrl ? `<a class="btn" href="${escH(maxUrl)}" target="_blank" rel="noopener" style="padding:7px 12px;font-size:12.5px">Подключить MAX</a>` : ''}
951
+ <button type="button" class="btn" id="cab-notify-test" style="padding:7px 12px;font-size:12.5px">Тест уведомления</button>
952
+ </div>
953
+ </div>
954
+
955
+ <div style="border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--bg)">
956
+ <div style="font-weight:700;margin-bottom:6px">💳 Биллинг, промокоды и история платежей</div>
957
+ <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:8px">
958
+ <input id="cab-promo-in" placeholder="Промокод или VIP-токен (VIP-…)" style="padding:7px 12px;border-radius:9px;border:1px solid var(--line);background:#070b14;color:var(--text);font-size:13px;flex:1;min-width:180px">
959
+ <button type="button" class="btn" id="cab-promo-btn" style="padding:7px 14px;font-size:12.5px">Активировать код</button>
960
+ </div>
961
+ ${payRowsHtml}
962
+ </div>
963
+ </div>
858
964
  </div>`;
859
965
  renderWorkspace();
860
966
 
967
+ document.getElementById('btn-close-pay-banner')?.addEventListener('click', () => {
968
+ paymentSuccessBanner = '';
969
+ renderCabinet();
970
+ });
971
+ document.getElementById('cab-logout-btn')?.addEventListener('click', () => doLogout());
972
+ document.getElementById('cab-verify-btn')?.addEventListener('click', () => verifyPaymentNow());
973
+ cab.querySelectorAll('[data-verify-pid]').forEach((b) => b.addEventListener('click', () => verifyPaymentNow(b.dataset.verifyPid)));
974
+ document.getElementById('cab-notify-test')?.addEventListener('click', async () => {
975
+ try {
976
+ const r = await fetch('/api/auth/notify-test', { method: 'POST' });
977
+ const j = await r.json();
978
+ if (!r.ok) throw new Error(j.error || 'Не удалось отправить тест');
979
+ alert('Тестовое уведомление отправлено в привязанный мессенджер!');
980
+ } catch (e) { alert(e.message); }
981
+ });
982
+
861
983
  document.getElementById('cab-promo-btn')?.addEventListener('click', () => {
862
984
  const code = document.getElementById('cab-promo-in')?.value.trim();
863
985
  if (!code) return;
@@ -870,48 +992,91 @@
870
992
  const j = await r.json();
871
993
  if (!r.ok) return alert(j.error || 'Ошибка активации кода');
872
994
  currentUser = j.user;
995
+ paymentSuccessBanner = `Код успешно активирован! Включён тариф «${currentUser.planName}».`;
873
996
  renderCabinet();
874
- alert(`Код успешно активирован! Ваш тариф: ${currentUser.planName}`);
875
- });
997
+ }, `Активация промокода / VIP-токена (${code})`);
876
998
  });
877
999
  }
878
1000
 
879
- // ---------- «Где работать»: облачная машина или установка на свой компьютер (VIP по договору) ----------
1001
+ async function verifyPaymentNow(paymentId = null, silent = false) {
1002
+ try {
1003
+ const r = await fetch('/api/billing/verify', {
1004
+ method: 'POST',
1005
+ headers: { 'Content-Type': 'application/json' },
1006
+ body: JSON.stringify(paymentId ? { paymentId } : {}),
1007
+ });
1008
+ const j = await r.json();
1009
+ if (!r.ok) throw new Error(j.error || 'Не удалось проверить статус платежа');
1010
+ if (j.user) currentUser = j.user;
1011
+ if (j.status === 'succeeded') {
1012
+ paymentSuccessBanner = `Оплата прошла успешно! Активен тариф «${currentUser?.planName || ''}». Ваша персональная виртуальная машина PaaS подготовлена ниже.`;
1013
+ renderCabinet();
1014
+ document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
1015
+ } else if (!silent) {
1016
+ renderCabinet();
1017
+ alert(j.status === 'none' ? 'Неоплаченных счетов в ожидании не найдено.' : `Текущий статус платежа в ЮKassa: ${j.status}`);
1018
+ }
1019
+ } catch (e) {
1020
+ if (!silent) alert(e.message);
1021
+ }
1022
+ }
1023
+
1024
+ // ---------- «Где работать»: персональная виртуальная машина PaaS или установка на свой компьютер (VIP по договору) ----------
880
1025
  async function renderWorkspace() {
881
1026
  const box = document.getElementById('cab-workspace');
882
1027
  if (!box || !currentUser) return;
883
- const escH = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
884
1028
  let me = null;
885
- try { me = await (await fetch('/api/auth/me')).json(); } catch { /* ignore */ }
886
- const list = me?.user?.instances || [];
1029
+ try {
1030
+ me = await (await fetch('/api/auth/me')).json();
1031
+ if (me?.user) {
1032
+ currentUser = me.user;
1033
+ lastMeMeta = me;
1034
+ }
1035
+ } catch { /* ignore */ }
1036
+ const list = me?.user?.instances || currentUser.instances || [];
887
1037
  const dl = me?.downloads || {};
888
1038
  const paid = currentUser.plan && currentUser.plan !== 'none' && currentUser.plan !== 'free';
889
1039
  const canDesktop = Boolean(currentUser.limits?.allowDesktop);
890
1040
  const ago = (ts) => {
891
- if (!ts) return 'ещё не выходила на связь';
1041
+ if (!ts) return 'ожидает первого запуска в PaaS';
892
1042
  const s = Math.round((Date.now() - ts) / 1000);
893
1043
  return s < 120 ? `${s} с назад` : s < 7200 ? `${Math.round(s / 60)} мин назад` : `${Math.round(s / 3600)} ч назад`;
894
1044
  };
895
1045
  const card = (title, body) => `<div style="flex:1 1 300px;border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--bg)"><div style="font-weight:700;margin-bottom:8px">${title}</div>${body}</div>`;
896
1046
 
897
1047
  const clouds = list.filter((i) => i.kind === 'cloud');
898
- const cloudBody = clouds.length
899
- ? clouds.map((i) => {
900
- const state = i.online ? '<span class="pos">работает</span>'
901
- : ['pending', 'deploying', 'deployed'].includes(i.provisionState) ? 'готовится к запуску (обычно 2–5 минут)'
902
- : i.provisionState === 'error' ? 'идёт повторное развёртывание' : `нет связи (${ago(i.lastSeen)})`;
903
- return `<div style="font-size:14px;margin-bottom:8px">Адрес: <b>${escH(i.host)}</b> · ${state}</div>
904
- <a class="btn primary" href="/api/instances/open?i=${encodeURIComponent(i.id)}">Перейти в терминал →</a>
905
- <div style="font-size:12.5px;color:var(--muted);margin-top:8px">Можно добавить адрес в закладки: вход через этот кабинет выполняется автоматически.</div>`;
906
- }).join('<hr style="border:0;border-top:1px solid var(--line);margin:10px 0">')
907
- : paid
908
- ? '<div style="font-size:14px;color:var(--muted)">Машина создаётся автоматически после оплаты — обновите страницу через пару минут.</div>'
909
- : '<div style="font-size:14px;color:var(--muted)">Оформите тариф — персональная облачная машина с ИИ будет создана автоматически.</div><a class="btn" href="#pricing" style="margin-top:8px">Выбрать тариф</a>';
1048
+ let cloudBody;
1049
+ if (currentUser.role === 'admin' && !clouds.length) {
1050
+ cloudBody = `<div style="font-size:13.5px;color:#cbd5e1;margin-bottom:10px">Для администратора («Хозяин») торговый терминал работает прямо на центральном сервере, а клиентские PaaS-машины управляются в консоли администратора.</div>
1051
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
1052
+ <a class="btn primary" href="/?terminal=1">Открыть торговый терминал →</a>
1053
+ <a class="btn" href="/admin">Управление машинами клиентов (/admin) →</a>
1054
+ </div>`;
1055
+ } else if (clouds.length) {
1056
+ cloudBody = clouds.map((i) => {
1057
+ const state = i.online
1058
+ ? '<span class="pos" style="font-weight:700">● работает в облаке PaaS</span>'
1059
+ : ['pending', 'deploying', 'deployed'].includes(i.provisionState)
1060
+ ? '<span style="color:#fbbf24">● выделена и готовится к запуску в PaaS</span>'
1061
+ : i.provisionState === 'error'
1062
+ ? '<span class="neg">● ожидает повторного развёртывания в PaaS</span>'
1063
+ : `<span style="color:var(--muted)">● ${ago(i.lastSeen)}</span>`;
1064
+ return `<div style="font-size:14px;margin-bottom:10px">Персональная машина: <b>${escH(i.host || i.id)}</b> · ${state}</div>
1065
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
1066
+ <a class="btn primary" href="/api/instances/open?i=${encodeURIComponent(i.id)}">Перейти в торговый терминал →</a>
1067
+ </div>
1068
+ <div style="font-size:12.5px;color:var(--muted);margin-top:8px">При нажатии вы автоматически войдёте в свой изолированный торговый терминал на виртуальной машине PaaS по защищённому одноразовому ключу.</div>`;
1069
+ }).join('<hr style="border:0;border-top:1px solid var(--line);margin:10px 0">');
1070
+ } else if (paid) {
1071
+ cloudBody = '<div style="font-size:14px;color:var(--muted)">Ваша персональная виртуальная машина PaaS создаётся автоматически после подтверждения подписки — нажмите «Проверить оплату» или обновите страницу через минуту.</div>';
1072
+ } else {
1073
+ cloudBody = '<div style="font-size:13.5px;color:var(--muted);margin-bottom:10px">Оформите стандартную подписку («Инвестор» или «Профи») — в облаке PaaS автоматически создастся ваша персональная виртуальная машина с торговым терминалом и ИИ-Стратегом.</div><a class="btn primary" href="#pricing">Выбрать тариф →</a>';
1074
+ }
910
1075
 
911
1076
  const desks = list.filter((i) => i.kind === 'desktop');
912
1077
  let deskBody;
913
1078
  if (!canDesktop) {
914
- deskBody = '<div style="font-size:14px;color:var(--muted)">Установка на свой компьютер (Windows, Linux или Docker) — по индивидуальному договору на тарифе VIP.</div><a class="btn" href="#pricing" style="margin-top:8px">Обсудить VIP</a>';
1079
+ deskBody = '<div style="font-size:13.5px;color:var(--muted);margin-bottom:10px">Установка автономного терминала на собственный компьютер (Windows, Linux или Docker) предоставляется по индивидуальному договору на тарифе VIP.</div><a class="btn" href="#pricing">Обсудить тариф VIP</a>';
915
1080
  } else {
916
1081
  const links = [
917
1082
  dl.windows ? `<a class="btn" href="${escH(dl.windows)}">⬇ Windows</a>` : '',
@@ -926,8 +1091,8 @@
926
1091
  <button type="button" class="btn primary" id="cab-desktop-code">Получить код активации</button>
927
1092
  <div id="cab-desktop-code-out" style="margin-top:8px;font-size:14px"></div>`;
928
1093
  }
929
- box.innerHTML = `<div class="sec-tag" style="margin-bottom:10px">Где работать</div>
930
- <div style="display:flex;gap:14px;flex-wrap:wrap">${card('☁ В облаке', cloudBody)}${card('💻 На своём компьютере', deskBody)}</div>`;
1094
+ box.innerHTML = `<div class="sec-tag" style="margin-bottom:10px">Ваша торговая машина и вход в терминал</div>
1095
+ <div style="display:flex;gap:14px;flex-wrap:wrap">${card('☁ Персональная виртуальная машина в PaaS', cloudBody)}${card('💻 Установка на свой компьютер (VIP)', deskBody)}</div>`;
931
1096
 
932
1097
  document.getElementById('cab-desktop-code')?.addEventListener('click', async () => {
933
1098
  const out = document.getElementById('cab-desktop-code-out');
@@ -971,16 +1136,17 @@
971
1136
  const pr = await fetch(`/api/auth/telegram/poll?code=${encodeURIComponent(j.code)}`).then((r) => r.json()).catch(() => ({}));
972
1137
  if (pr.status === 'done' && pr.user) {
973
1138
  clearInterval(tgPollTimer);
974
- currentUser = pr.user;
1139
+ const me = await (await fetch('/api/auth/me')).json().catch(() => ({ user: pr.user }));
1140
+ currentUser = me.user || pr.user;
1141
+ lastMeMeta = me;
975
1142
  document.getElementById('auth-modal').hidden = true;
976
- document.getElementById('btn-login-open').textContent = `${currentUser.name} · ${currentUser.planName}`;
977
1143
  renderCabinet();
978
1144
  if (pendingPlan) {
979
1145
  const p = pendingPlan;
980
1146
  pendingPlan = null;
981
1147
  await startCheckout(p);
982
1148
  } else {
983
- document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
1149
+ document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
984
1150
  }
985
1151
  } else if (pr.status === 'expired') {
986
1152
  clearInterval(tgPollTimer);
@@ -997,6 +1163,7 @@
997
1163
  async function loadShowcase() {
998
1164
  try {
999
1165
  const me = await (await fetch('/api/auth/me')).json();
1166
+ lastMeMeta = me;
1000
1167
  currentUser = me.user;
1001
1168
  let hasAnyFastLogin = false;
1002
1169
  if (me.notify?.tgBotName && me.notify?.hasTgToken) {
@@ -1024,14 +1191,24 @@
1024
1191
  if (up.get('oauth_error')) {
1025
1192
  openAuth(false);
1026
1193
  document.getElementById('auth-err').textContent = up.get('oauth_error');
1194
+ } else if (!currentUser && up.get('register') === '1') {
1195
+ openAuth(true);
1196
+ } else if (!currentUser && up.get('login') === '1') {
1197
+ openAuth(false);
1027
1198
  }
1199
+ syncNavAuth();
1028
1200
  if (currentUser) {
1029
- document.getElementById('btn-login-open').textContent = `${currentUser.name} · ${currentUser.planName}`;
1030
1201
  renderCabinet();
1031
- if (up.get('buy_plan')) {
1202
+ const hasPendingPay = Array.isArray(currentUser.payments) && currentUser.payments.some((p) => p.status === 'pending');
1203
+ if (up.get('payment_check') === '1' || hasPendingPay) {
1204
+ await verifyPaymentNow(null, up.get('payment_check') !== '1');
1205
+ if (up.get('payment_check') === '1' && history.replaceState) {
1206
+ history.replaceState(null, '', location.pathname);
1207
+ }
1208
+ } else if (up.get('buy_plan')) {
1032
1209
  startCheckout(up.get('buy_plan'));
1033
1210
  } else if (up.get('oauth_ok') === '1') {
1034
- document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
1211
+ document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
1035
1212
  }
1036
1213
  }
1037
1214
  const { runs } = await (await fetch('/api/public/showcase')).json();
@@ -1057,7 +1234,6 @@
1057
1234
  const tColor = r.tierColor || (isLong ? 'blue' : 'indigo');
1058
1235
  const tBadge = r.tierBadge || (isLong ? 'Для тарифа «Инвестор»' : 'Для тарифов «Профи» и «VIP»');
1059
1236
  const tNote = r.tierNote || (isLong ? 'Только покупки (без шорта)' : 'Покупки и продажи без покрытия (шорт)');
1060
- // Главная цифра — реалистичная оценка стресс-теста (исполнение как в живом контуре, издержки 0,10% на сторону)
1061
1237
  const rl = r.realistic;
1062
1238
  const badgeStyle = tColor === 'gold'
1063
1239
  ? 'background:rgba(245,158,11,0.18);border:1px solid rgba(245,158,11,0.5);color:#fbbf24'
@@ -1065,6 +1241,7 @@
1065
1241
  ? 'background:rgba(99,102,241,0.2);border:1px solid rgba(129,140,248,0.5);color:#a5b4fc'
1066
1242
  : 'background:rgba(59,130,246,0.16);border:1px solid rgba(96,165,250,0.45);color:#93c5fd';
1067
1243
  const cardBorder = tColor === 'gold' ? 'border-color:rgba(245,158,11,0.4)' : '';
1244
+ void cagr;
1068
1245
  return `<div class="sc-card" style="${cardBorder}">
1069
1246
  <div style="margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;gap:8px">
1070
1247
  <span style="display:inline-block;padding:3px 10px;border-radius:999px;font-size:11.5px;font-weight:700;letter-spacing:0.3px;${badgeStyle}">${tBadge}</span>
@@ -1115,15 +1292,46 @@
1115
1292
  isReg = reg;
1116
1293
  document.getElementById('auth-modal').hidden = false;
1117
1294
  document.getElementById('auth-name').hidden = !reg;
1118
- document.getElementById('auth-title').textContent = reg ? 'Регистрация в НейроАльфа' : 'Вход в НейроАльфа';
1119
- document.getElementById('auth-submit').textContent = reg ? 'Создать аккаунт' : 'Войти';
1295
+ const offerRow = document.getElementById('auth-offer-row');
1296
+ const offerChk = document.getElementById('auth-offer-chk');
1297
+ if (offerRow) offerRow.style.display = reg ? 'flex' : 'none';
1298
+ if (offerChk) offerChk.required = Boolean(reg);
1299
+ const tabReg = document.getElementById('tab-auth-reg');
1300
+ const tabLogin = document.getElementById('tab-auth-login');
1301
+ if (tabReg) tabReg.className = reg ? 'btn primary' : 'btn';
1302
+ if (tabLogin) tabLogin.className = !reg ? 'btn primary' : 'btn';
1303
+ const planBanner = document.getElementById('auth-plan-banner');
1304
+ if (planBanner) {
1305
+ if (pendingPlan) {
1306
+ planBanner.hidden = false;
1307
+ planBanner.innerHTML = `<b>Шаг 1 из 2:</b> ${reg ? 'Создайте личный аккаунт клиента' : 'Войдите в свой аккаунт'}, чтобы перейти к оплате тарифа <b>«${escH(PLAN_TITLES[pendingPlan] || pendingPlan)}»</b>.`;
1308
+ } else {
1309
+ planBanner.hidden = true;
1310
+ }
1311
+ }
1312
+ document.getElementById('auth-title').textContent = reg ? 'Регистрация нового клиента' : 'Вход в личный кабинет';
1313
+ document.getElementById('auth-submit').textContent = reg ? (pendingPlan ? 'Создать аккаунт и перейти к оплате →' : 'Создать аккаунт') : (pendingPlan ? 'Войти и перейти к оплате →' : 'Войти');
1120
1314
  document.getElementById('auth-toggle').textContent = reg ? 'Уже есть аккаунт? Войти' : 'Нет аккаунта? Зарегистрироваться';
1121
1315
  document.getElementById('auth-err').textContent = '';
1122
1316
  }
1123
1317
 
1124
- document.getElementById('btn-login-open').addEventListener('click', () => openAuth(false));
1125
- document.getElementById('btn-hero-start').addEventListener('click', () => currentUser ? document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'center' }) : openAuth(true));
1126
- document.getElementById('btn-free-start')?.addEventListener('click', () => currentUser ? document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'center' }) : openAuth(true));
1318
+ document.getElementById('tab-auth-reg')?.addEventListener('click', () => openAuth(true));
1319
+ document.getElementById('tab-auth-login')?.addEventListener('click', () => openAuth(false));
1320
+ document.getElementById('btn-login-open').addEventListener('click', () => {
1321
+ if (currentUser) {
1322
+ document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
1323
+ } else {
1324
+ pendingPlan = null;
1325
+ openAuth(false);
1326
+ }
1327
+ });
1328
+ document.getElementById('btn-reg-open')?.addEventListener('click', () => {
1329
+ pendingPlan = null;
1330
+ openAuth(true);
1331
+ });
1332
+ document.getElementById('btn-nav-logout')?.addEventListener('click', () => doLogout());
1333
+ document.getElementById('btn-hero-start').addEventListener('click', () => currentUser ? document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'start' }) : openAuth(true));
1334
+ document.getElementById('btn-free-start')?.addEventListener('click', () => currentUser ? document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'start' }) : openAuth(true));
1127
1335
  document.getElementById('auth-close').addEventListener('click', () => { document.getElementById('auth-modal').hidden = true; });
1128
1336
  document.getElementById('auth-toggle').addEventListener('click', () => openAuth(!isReg));
1129
1337
 
@@ -1139,11 +1347,27 @@
1139
1347
  offerChk1?.addEventListener('change', syncOfferBtn);
1140
1348
  offerChk2?.addEventListener('change', syncOfferBtn);
1141
1349
 
1142
- function requireOfferAgreement(actionFn) {
1350
+ function requireOfferAgreement(actionFn, planLabel = '') {
1143
1351
  onOfferAccepted = actionFn;
1144
1352
  if (offerChk1) offerChk1.checked = false;
1145
1353
  if (offerChk2) offerChk2.checked = false;
1146
1354
  syncOfferBtn();
1355
+ const accBanner = document.getElementById('offer-account-banner');
1356
+ if (accBanner && currentUser) {
1357
+ const isAdmin = currentUser.role === 'admin';
1358
+ accBanner.innerHTML = `<div style="padding:10px 14px;border-radius:10px;background:${isAdmin ? 'rgba(245,158,11,0.16)' : 'rgba(59,130,246,0.12)'};border:1px solid ${isAdmin ? 'rgba(245,158,11,0.5)' : 'rgba(96,165,250,0.35)'};font-size:13px;line-height:1.5">
1359
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
1360
+ <span>Аккаунт покупателя: <b>${escH(currentUser.name)}</b> (<code>${escH(currentUser.email)}</code>)${planLabel ? ` · <b>${escH(planLabel)}</b>` : ''}</span>
1361
+ <button type="button" class="btn" id="btn-offer-switch-user" style="padding:4px 10px;font-size:12px">Выйти и зарегистрировать другого клиента</button>
1362
+ </div>
1363
+ ${isAdmin ? '<div style="margin-top:6px;color:#fde68a">⚠ <b>Внимание:</b> вы сейчас авторизованы под учётной записью администратора («Хозяин»). Чтобы протестировать регистрацию и покупку подписки как новый клиент, нажмите кнопку <b>«Выйти и зарегистрировать другого клиента»</b>.</div>' : ''}
1364
+ </div>`;
1365
+ document.getElementById('btn-offer-switch-user')?.addEventListener('click', () => {
1366
+ offerModal.hidden = true;
1367
+ onOfferAccepted = null;
1368
+ doLogout(pendingPlan || 'investor');
1369
+ });
1370
+ }
1147
1371
  offerModal.hidden = false;
1148
1372
  }
1149
1373
 
@@ -1164,6 +1388,8 @@
1164
1388
  });
1165
1389
 
1166
1390
  async function startCheckout(plan) {
1391
+ pendingPlan = plan;
1392
+ const planLabel = `Тариф «${PLAN_TITLES[plan] || plan}» (${selectedMonths} мес.)`;
1167
1393
  requireOfferAgreement(async () => {
1168
1394
  try {
1169
1395
  const res = await fetch('/api/billing/checkout', {
@@ -1177,7 +1403,7 @@
1177
1403
  } catch (e) {
1178
1404
  alert(e.message);
1179
1405
  }
1180
- });
1406
+ }, planLabel);
1181
1407
  }
1182
1408
 
1183
1409
  document.getElementById('btn-vip-req')?.addEventListener('click', () => {
@@ -1223,9 +1449,9 @@
1223
1449
  const j = await r.json();
1224
1450
  if (!r.ok) return alert(j.error || 'Не удалось активировать VIP-токен');
1225
1451
  currentUser = j.user;
1452
+ paymentSuccessBanner = `Персональный VIP-токен активирован! Ваш тариф: «${currentUser.planName}».`;
1226
1453
  renderCabinet();
1227
- alert(`Персональный VIP-токен активирован! Ваш тариф: ${currentUser.planName}`);
1228
- });
1454
+ }, `Активация VIP-токена (${code})`);
1229
1455
  });
1230
1456
 
1231
1457
  document.querySelectorAll('[data-buy]').forEach((b) => b.addEventListener('click', () => {
@@ -1250,16 +1476,17 @@
1250
1476
  });
1251
1477
  const j = await res.json();
1252
1478
  if (!res.ok) throw new Error(j.error || 'Ошибка авторизации');
1253
- currentUser = j.user;
1479
+ const me = await (await fetch('/api/auth/me')).json().catch(() => ({ user: j.user }));
1480
+ currentUser = me.user || j.user;
1481
+ lastMeMeta = me;
1254
1482
  document.getElementById('auth-modal').hidden = true;
1255
- document.getElementById('btn-login-open').textContent = `${currentUser.name} · ${currentUser.planName}`;
1256
1483
  renderCabinet();
1257
1484
  if (pendingPlan) {
1258
1485
  const p = pendingPlan;
1259
1486
  pendingPlan = null;
1260
1487
  await startCheckout(p);
1261
1488
  } else {
1262
- document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
1489
+ document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
1263
1490
  }
1264
1491
  } catch (err) {
1265
1492
  document.getElementById('auth-err').textContent = err.message;
package/public/legal.html CHANGED
@@ -6,10 +6,11 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1">
7
7
  <title>Правовые документы · НейроАльфа</title>
8
8
  <style>
9
- :root { --bg: #0b0e14; --panel: #131822; --line: #242c3d; --text: #eef2f6; --muted: #94a3b8; --accent: #3b82f6; }
9
+ :root { color-scheme: dark; --bg: #0b0e14; --panel: #131822; --line: #242c3d; --text: #eef2f6; --muted: #94a3b8; --accent: #3b82f6; }
10
10
  * { box-sizing: border-box; }
11
11
  body { margin: 0; background: var(--bg); color: var(--text); font: 15.5px/1.65 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
12
- a { color: var(--accent); text-decoration: none; }
12
+ a, a:visited { color: var(--accent); text-decoration: none; }
13
+ a.brand, a.brand:visited { color: var(--text); }
13
14
  .wrap { max-width: 900px; margin: 0 auto; padding: 36px 24px 70px; }
14
15
  .top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
15
16
  .brand { font-weight: 700; font-size: 19px; color: var(--text); }