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.
- package/package.json +61 -60
- package/public/admin.html +603 -569
- package/public/app.js +101 -23
- package/public/landing.html +301 -74
- package/public/legal.html +3 -2
- package/public/splat3d.html +671 -0
- package/public/styles.css +35 -13
- package/scripts/set-admin-password.mjs +30 -0
- package/server/api/routes.js +41 -14
- package/server/auth/index.js +35 -4
- package/server/billing/yookassa.js +142 -133
- package/server/brokers/index.js +81 -50
- package/server/data/moex.js +6 -5
- package/server/db.js +1 -1
- package/server/index.js +3 -3
- package/server/instance/central.js +514 -505
- package/server/model/dataset.js +8 -9
- package/server/model/edge.js +41 -19
- package/server/model/forecast.js +111 -17
- package/server/model/live.js +16 -18
- package/server/model/research.js +41 -15
- package/server/strategist/index.js +758 -757
- package/server/strategist/tools.js +707 -675
package/public/landing.html
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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">Личный кабинет
|
|
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:
|
|
843
|
-
Тариф: <b class="pos">${currentUser.planName}</b> (${untilStr}) · Брокерских счетов: до <b>${currentUser.limits?.maxBrokerAccounts ?? 0}</b> (${capLim}) · Режим: <b>${currentUser.limits?.allowShorts ? 'в рост и в падение (с шортом)' : 'только в рост (без шорта)'}</b
|
|
844
|
-
|
|
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:
|
|
851
|
-
<div style="display:flex;gap:
|
|
852
|
-
<a class="btn" href="#pricing" style="flex:1"
|
|
853
|
-
|
|
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
|
-
|
|
875
|
-
});
|
|
997
|
+
}, `Активация промокода / VIP-токена (${code})`);
|
|
876
998
|
});
|
|
877
999
|
}
|
|
878
1000
|
|
|
879
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
884
1028
|
let me = null;
|
|
885
|
-
try {
|
|
886
|
-
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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:
|
|
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"
|
|
930
|
-
<div style="display:flex;gap:14px;flex-wrap:wrap">${card('☁
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
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: '
|
|
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-
|
|
1119
|
-
document.getElementById('auth-
|
|
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('
|
|
1125
|
-
document.getElementById('
|
|
1126
|
-
document.getElementById('btn-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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); }
|