neuroalpha 0.5.17 → 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.
@@ -333,10 +333,13 @@
333
333
  <a href="#paas-sec">Как работает PaaS</a>
334
334
  <a href="#brokers">Брокеры и мессенджеры</a>
335
335
  <a href="#pricing">Тарифы</a>
336
+ <a href="/splat3d.html">3D-демо</a>
336
337
  </nav>
337
- <div style="display:flex;gap:10px" id="nav-auth">
338
- <button class="btn" id="btn-login-open">Войти</button>
339
- <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>
340
343
  </div>
341
344
  </div>
342
345
  </header>
@@ -600,7 +603,12 @@
600
603
 
601
604
  <div class="modal" id="auth-modal" hidden>
602
605
  <div class="modal-box">
603
- <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>
604
612
  <div id="tg-bot-login-box" hidden style="margin-bottom:8px">
605
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">
606
614
  <span class="oauth-ico" style="background:#24a1de;color:#fff">✈</span>
@@ -637,10 +645,10 @@
637
645
  </div>
638
646
  <div class="or-sep" id="oauth-or-sep" hidden>или по Email и паролю</div>
639
647
  <form id="auth-form">
640
- <input name="name" id="auth-name" placeholder="Ваше имя" hidden>
641
- <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">
642
650
  <input name="password" type="password" required minlength="6" placeholder="Пароль (не короче 6 символов)">
643
- <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>
644
652
  <div class="err" id="auth-err"></div>
645
653
  <button class="btn primary" type="submit" id="auth-submit">Войти</button>
646
654
  <button class="btn" type="button" id="auth-toggle">Нет аккаунта? Зарегистрироваться</button>
@@ -653,6 +661,7 @@
653
661
  <div class="modal" id="offer-modal" hidden>
654
662
  <div class="modal-box" style="max-width:660px">
655
663
  <h3>Договор публичной оферты и Декларация о рисках</h3>
664
+ <div id="offer-account-banner" style="margin-bottom:12px"></div>
656
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">
657
666
  <p style="margin-top:0"><b>1. Предмет договора:</b> Настоящая Публичная оферта регулирует предоставление доступа к облачной аналитической платформе «НейроАльфа» (PaaS/SaaS) согласно выбранному тарифному плану («Инвестор», «Профи», «VIP»).</p>
658
667
  <p><b>2. Правовой статус (отсутствие ИИР и ДУ):</b> Сервис является исключительно программно-техническим средством (калькулятором статистических закономерностей). Исполнитель <b>не осуществляет инвестиционное консультирование</b>, не даёт индивидуальных инвестиционных рекомендаций (ФЗ № 39-ФЗ) и не осуществляет доверительное управление. Все сделки совершаются Пользователем самостоятельно на его личном брокерском счёте.</p>
@@ -809,56 +818,168 @@
809
818
  </svg>`;
810
819
  }
811
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
+
812
870
  function renderCabinet() {
813
871
  const cab = document.getElementById('cabinet-box');
872
+ syncNavAuth();
814
873
  if (!cab) return;
815
874
  if (!currentUser) {
816
875
  cab.hidden = true;
817
876
  return;
818
877
  }
819
878
  cab.hidden = false;
820
- const escH = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
821
- // Терминал клиента работает на его машине (облако naXXXXXX.odant.org или свой компьютер) — см. блок «Где работать»
822
- const navCta = document.getElementById('btn-nav-cta');
823
- if (navCta) {
824
- if (currentUser.role === 'admin') {
825
- navCta.href = '/admin';
826
- navCta.textContent = 'Панель админа →';
827
- navCta.hidden = false;
828
- } else {
829
- navCta.hidden = true;
830
- }
831
- }
832
879
  const untilStr = currentUser.planUntil > Date.now()
833
880
  ? `оплачен до ${new Date(currentUser.planUntil).toLocaleDateString('ru-RU')}`
834
- : 'бессрочно';
881
+ : (currentUser.plan && currentUser.plan !== 'none' ? 'бессрочно' : 'подписка не активна');
835
882
  const capLim = currentUser.limits?.maxCapitalPerAccountRub
836
883
  ? (currentUser.limits.maxCapitalPerAccountRub >= 100000000 ? 'без ограничений' : `до ${(currentUser.limits.maxCapitalPerAccountRub / 1000000).toLocaleString('ru-RU')} млн ₽/счёт`)
837
- : 'демо-режим';
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
+
838
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>` : ''}
839
918
  <div class="cab-grid">
840
919
  <div>
841
- <div class="sec-tag" style="margin-bottom:4px">Личный кабинет PaaS · Аккаунт активен</div>
920
+ <div class="sec-tag" style="margin-bottom:4px">Личный кабинет клиента · ${currentUser.role === 'admin' ? 'Администратор платформы' : 'Аккаунт активен'}</div>
842
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>
843
- <div style="margin-bottom:14px;font-size:14.5px;color:#cbd5e1">
844
- Тариф: <b class="pos">${currentUser.planName}</b> (${untilStr}) · Брокерских счетов: до <b>${currentUser.limits?.maxBrokerAccounts ?? 0}</b> (${capLim}) · Режим: <b>${currentUser.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)'}</b>.
845
- </div>
846
- <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
847
- <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">
848
- <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}
849
925
  </div>
850
926
  </div>
851
- <div style="display:flex;flex-direction:column;gap:10px">
852
- <div style="display:flex;gap:10px;flex-wrap:wrap">
853
- <a class="btn" href="#pricing" style="flex:1">Сменить / продлить тариф</a>
854
- ${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>
855
935
  </div>
856
936
  </div>
857
937
  </div>
938
+
858
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>
859
964
  </div>`;
860
965
  renderWorkspace();
861
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
+
862
983
  document.getElementById('cab-promo-btn')?.addEventListener('click', () => {
863
984
  const code = document.getElementById('cab-promo-in')?.value.trim();
864
985
  if (!code) return;
@@ -871,48 +992,91 @@
871
992
  const j = await r.json();
872
993
  if (!r.ok) return alert(j.error || 'Ошибка активации кода');
873
994
  currentUser = j.user;
995
+ paymentSuccessBanner = `Код успешно активирован! Включён тариф «${currentUser.planName}».`;
874
996
  renderCabinet();
875
- alert(`Код успешно активирован! Ваш тариф: ${currentUser.planName}`);
876
- });
997
+ }, `Активация промокода / VIP-токена (${code})`);
877
998
  });
878
999
  }
879
1000
 
880
- // ---------- «Где работать»: облачная машина или установка на свой компьютер (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 по договору) ----------
881
1025
  async function renderWorkspace() {
882
1026
  const box = document.getElementById('cab-workspace');
883
1027
  if (!box || !currentUser) return;
884
- const escH = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
885
1028
  let me = null;
886
- try { me = await (await fetch('/api/auth/me')).json(); } catch { /* ignore */ }
887
- 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 || [];
888
1037
  const dl = me?.downloads || {};
889
1038
  const paid = currentUser.plan && currentUser.plan !== 'none' && currentUser.plan !== 'free';
890
1039
  const canDesktop = Boolean(currentUser.limits?.allowDesktop);
891
1040
  const ago = (ts) => {
892
- if (!ts) return 'ещё не выходила на связь';
1041
+ if (!ts) return 'ожидает первого запуска в PaaS';
893
1042
  const s = Math.round((Date.now() - ts) / 1000);
894
1043
  return s < 120 ? `${s} с назад` : s < 7200 ? `${Math.round(s / 60)} мин назад` : `${Math.round(s / 3600)} ч назад`;
895
1044
  };
896
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>`;
897
1046
 
898
1047
  const clouds = list.filter((i) => i.kind === 'cloud');
899
- const cloudBody = clouds.length
900
- ? clouds.map((i) => {
901
- const state = i.online ? '<span class="pos">работает</span>'
902
- : ['pending', 'deploying', 'deployed'].includes(i.provisionState) ? 'готовится к запуску (обычно 2–5 минут)'
903
- : i.provisionState === 'error' ? 'идёт повторное развёртывание' : `нет связи (${ago(i.lastSeen)})`;
904
- return `<div style="font-size:14px;margin-bottom:8px">Адрес: <b>${escH(i.host)}</b> · ${state}</div>
905
- <a class="btn primary" href="/api/instances/open?i=${encodeURIComponent(i.id)}">Перейти в терминал →</a>
906
- <div style="font-size:12.5px;color:var(--muted);margin-top:8px">Можно добавить адрес в закладки: вход через этот кабинет выполняется автоматически.</div>`;
907
- }).join('<hr style="border:0;border-top:1px solid var(--line);margin:10px 0">')
908
- : paid
909
- ? '<div style="font-size:14px;color:var(--muted)">Машина создаётся автоматически после оплаты — обновите страницу через пару минут.</div>'
910
- : '<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
+ }
911
1075
 
912
1076
  const desks = list.filter((i) => i.kind === 'desktop');
913
1077
  let deskBody;
914
1078
  if (!canDesktop) {
915
- 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>';
916
1080
  } else {
917
1081
  const links = [
918
1082
  dl.windows ? `<a class="btn" href="${escH(dl.windows)}">⬇ Windows</a>` : '',
@@ -927,8 +1091,8 @@
927
1091
  <button type="button" class="btn primary" id="cab-desktop-code">Получить код активации</button>
928
1092
  <div id="cab-desktop-code-out" style="margin-top:8px;font-size:14px"></div>`;
929
1093
  }
930
- box.innerHTML = `<div class="sec-tag" style="margin-bottom:10px">Где работать</div>
931
- <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>`;
932
1096
 
933
1097
  document.getElementById('cab-desktop-code')?.addEventListener('click', async () => {
934
1098
  const out = document.getElementById('cab-desktop-code-out');
@@ -972,16 +1136,17 @@
972
1136
  const pr = await fetch(`/api/auth/telegram/poll?code=${encodeURIComponent(j.code)}`).then((r) => r.json()).catch(() => ({}));
973
1137
  if (pr.status === 'done' && pr.user) {
974
1138
  clearInterval(tgPollTimer);
975
- 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;
976
1142
  document.getElementById('auth-modal').hidden = true;
977
- document.getElementById('btn-login-open').textContent = `${currentUser.name} · ${currentUser.planName}`;
978
1143
  renderCabinet();
979
1144
  if (pendingPlan) {
980
1145
  const p = pendingPlan;
981
1146
  pendingPlan = null;
982
1147
  await startCheckout(p);
983
1148
  } else {
984
- document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
1149
+ document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
985
1150
  }
986
1151
  } else if (pr.status === 'expired') {
987
1152
  clearInterval(tgPollTimer);
@@ -998,6 +1163,7 @@
998
1163
  async function loadShowcase() {
999
1164
  try {
1000
1165
  const me = await (await fetch('/api/auth/me')).json();
1166
+ lastMeMeta = me;
1001
1167
  currentUser = me.user;
1002
1168
  let hasAnyFastLogin = false;
1003
1169
  if (me.notify?.tgBotName && me.notify?.hasTgToken) {
@@ -1025,14 +1191,24 @@
1025
1191
  if (up.get('oauth_error')) {
1026
1192
  openAuth(false);
1027
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);
1028
1198
  }
1199
+ syncNavAuth();
1029
1200
  if (currentUser) {
1030
- document.getElementById('btn-login-open').textContent = `${currentUser.name} · ${currentUser.planName}`;
1031
1201
  renderCabinet();
1032
- 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')) {
1033
1209
  startCheckout(up.get('buy_plan'));
1034
1210
  } else if (up.get('oauth_ok') === '1') {
1035
- document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
1211
+ document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
1036
1212
  }
1037
1213
  }
1038
1214
  const { runs } = await (await fetch('/api/public/showcase')).json();
@@ -1058,7 +1234,6 @@
1058
1234
  const tColor = r.tierColor || (isLong ? 'blue' : 'indigo');
1059
1235
  const tBadge = r.tierBadge || (isLong ? 'Для тарифа «Инвестор»' : 'Для тарифов «Профи» и «VIP»');
1060
1236
  const tNote = r.tierNote || (isLong ? 'Только покупки (без шорта)' : 'Покупки и продажи без покрытия (шорт)');
1061
- // Главная цифра — реалистичная оценка стресс-теста (исполнение как в живом контуре, издержки 0,10% на сторону)
1062
1237
  const rl = r.realistic;
1063
1238
  const badgeStyle = tColor === 'gold'
1064
1239
  ? 'background:rgba(245,158,11,0.18);border:1px solid rgba(245,158,11,0.5);color:#fbbf24'
@@ -1066,6 +1241,7 @@
1066
1241
  ? 'background:rgba(99,102,241,0.2);border:1px solid rgba(129,140,248,0.5);color:#a5b4fc'
1067
1242
  : 'background:rgba(59,130,246,0.16);border:1px solid rgba(96,165,250,0.45);color:#93c5fd';
1068
1243
  const cardBorder = tColor === 'gold' ? 'border-color:rgba(245,158,11,0.4)' : '';
1244
+ void cagr;
1069
1245
  return `<div class="sc-card" style="${cardBorder}">
1070
1246
  <div style="margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;gap:8px">
1071
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>
@@ -1116,15 +1292,46 @@
1116
1292
  isReg = reg;
1117
1293
  document.getElementById('auth-modal').hidden = false;
1118
1294
  document.getElementById('auth-name').hidden = !reg;
1119
- document.getElementById('auth-title').textContent = reg ? 'Регистрация в НейроАльфа' : 'Вход в НейроАльфа';
1120
- 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 ? 'Войти и перейти к оплате →' : 'Войти');
1121
1314
  document.getElementById('auth-toggle').textContent = reg ? 'Уже есть аккаунт? Войти' : 'Нет аккаунта? Зарегистрироваться';
1122
1315
  document.getElementById('auth-err').textContent = '';
1123
1316
  }
1124
1317
 
1125
- document.getElementById('btn-login-open').addEventListener('click', () => openAuth(false));
1126
- document.getElementById('btn-hero-start').addEventListener('click', () => currentUser ? document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'center' }) : openAuth(true));
1127
- 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));
1128
1335
  document.getElementById('auth-close').addEventListener('click', () => { document.getElementById('auth-modal').hidden = true; });
1129
1336
  document.getElementById('auth-toggle').addEventListener('click', () => openAuth(!isReg));
1130
1337
 
@@ -1140,11 +1347,27 @@
1140
1347
  offerChk1?.addEventListener('change', syncOfferBtn);
1141
1348
  offerChk2?.addEventListener('change', syncOfferBtn);
1142
1349
 
1143
- function requireOfferAgreement(actionFn) {
1350
+ function requireOfferAgreement(actionFn, planLabel = '') {
1144
1351
  onOfferAccepted = actionFn;
1145
1352
  if (offerChk1) offerChk1.checked = false;
1146
1353
  if (offerChk2) offerChk2.checked = false;
1147
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
+ }
1148
1371
  offerModal.hidden = false;
1149
1372
  }
1150
1373
 
@@ -1165,6 +1388,8 @@
1165
1388
  });
1166
1389
 
1167
1390
  async function startCheckout(plan) {
1391
+ pendingPlan = plan;
1392
+ const planLabel = `Тариф «${PLAN_TITLES[plan] || plan}» (${selectedMonths} мес.)`;
1168
1393
  requireOfferAgreement(async () => {
1169
1394
  try {
1170
1395
  const res = await fetch('/api/billing/checkout', {
@@ -1178,7 +1403,7 @@
1178
1403
  } catch (e) {
1179
1404
  alert(e.message);
1180
1405
  }
1181
- });
1406
+ }, planLabel);
1182
1407
  }
1183
1408
 
1184
1409
  document.getElementById('btn-vip-req')?.addEventListener('click', () => {
@@ -1224,9 +1449,9 @@
1224
1449
  const j = await r.json();
1225
1450
  if (!r.ok) return alert(j.error || 'Не удалось активировать VIP-токен');
1226
1451
  currentUser = j.user;
1452
+ paymentSuccessBanner = `Персональный VIP-токен активирован! Ваш тариф: «${currentUser.planName}».`;
1227
1453
  renderCabinet();
1228
- alert(`Персональный VIP-токен активирован! Ваш тариф: ${currentUser.planName}`);
1229
- });
1454
+ }, `Активация VIP-токена (${code})`);
1230
1455
  });
1231
1456
 
1232
1457
  document.querySelectorAll('[data-buy]').forEach((b) => b.addEventListener('click', () => {
@@ -1251,16 +1476,17 @@
1251
1476
  });
1252
1477
  const j = await res.json();
1253
1478
  if (!res.ok) throw new Error(j.error || 'Ошибка авторизации');
1254
- 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;
1255
1482
  document.getElementById('auth-modal').hidden = true;
1256
- document.getElementById('btn-login-open').textContent = `${currentUser.name} · ${currentUser.planName}`;
1257
1483
  renderCabinet();
1258
1484
  if (pendingPlan) {
1259
1485
  const p = pendingPlan;
1260
1486
  pendingPlan = null;
1261
1487
  await startCheckout(p);
1262
1488
  } else {
1263
- document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
1489
+ document.getElementById('cabinet-box')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
1264
1490
  }
1265
1491
  } catch (err) {
1266
1492
  document.getElementById('auth-err').textContent = err.message;