[AI] fix some modules, develop the big part of modules. TODO fix critical errors, update pipeline, test everything
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Shield, Server, Activity, Plus, Power, RefreshCw } from "lucide-react";
|
||||
import { TOKEN_KEY, API_BASE } from "./api"; // Добавили API_BASE импорт
|
||||
import { TOKEN_KEY, API_BASE } from "./api";
|
||||
|
||||
interface VpnNode {
|
||||
id: string;
|
||||
@@ -20,6 +20,7 @@ export default function AdminDashboard() {
|
||||
const [name, setName] = useState("");
|
||||
const [ip, setIp] = useState("");
|
||||
const [code, setCode] = useState("de");
|
||||
const [sshPassword, setSshPassword] = useState(""); // <-- Добавлен стейт пароля
|
||||
|
||||
const fetchNodes = async () => {
|
||||
try {
|
||||
@@ -41,10 +42,23 @@ export default function AdminDashboard() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes();
|
||||
let isMounted = true;
|
||||
|
||||
const loadData = async () => {
|
||||
// Это предотвратит вызов setState если компонент успел размонтироваться
|
||||
if (isMounted) {
|
||||
await fetchNodes();
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAddNode = async (e: React.FormEvent) => {
|
||||
const handleAddNode = async (e: React.SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
@@ -60,12 +74,16 @@ export default function AdminDashboard() {
|
||||
country_code: code,
|
||||
ip_address: ip,
|
||||
port: 443,
|
||||
ssh_password: sshPassword, // <-- Пароль теперь передается на бэкенд
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setName("");
|
||||
setIp("");
|
||||
setSshPassword(""); // <-- Очищаем пароль после успеха
|
||||
fetchNodes();
|
||||
} else {
|
||||
console.error("Failed to add node:", await res.text());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -143,6 +161,22 @@ export default function AdminDashboard() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Поле для ввода временного SSH-пароля */}
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||
Root SSH Password (Temporary)
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
value={sshPassword}
|
||||
onChange={(e) => setSshPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm focus:border-secondary outline-none transition-colors mt-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full mt-4 bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/30 font-bold uppercase tracking-widest text-xs py-3 rounded-lg transition-all flex items-center justify-center gap-2"
|
||||
@@ -210,4 +244,4 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+59
-38
@@ -1,55 +1,71 @@
|
||||
import { useEffect, useState, Suspense, lazy } from "react";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { TonConnectUIProvider } from "@tonconnect/ui-react";
|
||||
import { TOKEN_KEY } from "./api";
|
||||
|
||||
const Profile = lazy(() => import("./Profile"));
|
||||
const HackGame = lazy(() => import("./HackGame"));
|
||||
const AdminDashboard = lazy(() => import("./AdminDashboard"));
|
||||
|
||||
function AuthHandler({ children }: { children: React.ReactNode }) {
|
||||
const [isProcessing, setIsProcessing] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const DICT = {
|
||||
en: {
|
||||
unauthorized: "[ERROR] UNAUTHORIZED ACCESS",
|
||||
loading: "Loading protocols...",
|
||||
},
|
||||
ru: {
|
||||
unauthorized: "[ОШИБКА] ДОСТУП ЗАПРЕЩЕН",
|
||||
loading: "Загрузка протоколов...",
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const getLang = (): "en" | "ru" => {
|
||||
const l = localStorage.getItem("lang");
|
||||
return l === "ru" ? "ru" : "en";
|
||||
};
|
||||
|
||||
function AuthHandler({ children }: { children: React.ReactNode }) {
|
||||
const [lang] = useState<"en" | "ru">(getLang);
|
||||
const t = DICT[lang];
|
||||
|
||||
// 1. Вычисляем статус авторизации синхронно ДО первого рендера
|
||||
const [isAuthenticated] = useState(() => {
|
||||
let token = localStorage.getItem(TOKEN_KEY);
|
||||
const hash = window.location.hash;
|
||||
|
||||
// Извлекаем токен из URL (если есть)
|
||||
// Если токен есть в URL, он приоритетнее
|
||||
if (hash.includes("token=")) {
|
||||
const params = new URLSearchParams(hash.replace("#", "?"));
|
||||
const urlToken = params.get("token");
|
||||
if (urlToken) {
|
||||
token = urlToken;
|
||||
}
|
||||
}
|
||||
|
||||
return !!token || import.meta.env.DEV;
|
||||
});
|
||||
|
||||
// 2. В эффекте делаем только "сайд-эффекты" браузера без вызова setState!
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash;
|
||||
|
||||
if (hash.includes("token=")) {
|
||||
const params = new URLSearchParams(hash.replace("#", "?"));
|
||||
const token = params.get("token");
|
||||
|
||||
if (token) {
|
||||
// Сохраняем токен и чистим URL (это сайд-эффекты, им самое место тут)
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка авторизации: либо наличие токена, либо режим разработки
|
||||
const hasToken = !!localStorage.getItem(TOKEN_KEY);
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
if (hasToken || isDev) {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
|
||||
setIsProcessing(false);
|
||||
}, []);
|
||||
|
||||
if (isProcessing) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center bg-[#05050A]">
|
||||
<span className="text-secondary animate-pulse font-mono tracking-widest text-sm uppercase">
|
||||
Инициализация...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Если не авторизован и не в DEV режиме — показываем лаконичную ошибку
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center bg-[#05050A] font-mono p-6 text-center">
|
||||
<h1 className="text-[#FF003C] text-lg tracking-widest uppercase animate-pulse">
|
||||
[ERROR] UNAUTHORIZED ACCESS
|
||||
{t.unauthorized}
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
@@ -60,7 +76,7 @@ function AuthHandler({ children }: { children: React.ReactNode }) {
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center min-h-screen text-secondary font-mono">
|
||||
Загрузка протоколов...
|
||||
{t.loading}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -71,16 +87,21 @@ function AuthHandler({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// Динамически получаем базовый URL, чтобы работало и на localhost, и на реальном домене
|
||||
const manifestUrl = `${window.location.origin}/tonconnect-manifest.json`;
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthHandler>
|
||||
<Routes>
|
||||
<Route path="/" element={<Profile />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/hack" element={<HackGame />} />
|
||||
<Route path="/admin" element={<AdminDashboard />} />
|
||||
</Routes>
|
||||
</AuthHandler>
|
||||
</BrowserRouter>
|
||||
<TonConnectUIProvider manifestUrl={manifestUrl}>
|
||||
<BrowserRouter>
|
||||
<AuthHandler>
|
||||
<Routes>
|
||||
<Route path="/" element={<Profile />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/hack" element={<HackGame />} />
|
||||
<Route path="/admin" element={<AdminDashboard />} />
|
||||
</Routes>
|
||||
</AuthHandler>
|
||||
</BrowserRouter>
|
||||
</TonConnectUIProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
+156
-32
@@ -7,11 +7,22 @@ import {
|
||||
ShieldCheck,
|
||||
Loader2,
|
||||
Terminal,
|
||||
Key,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface TelegramAuthUser {
|
||||
id: number;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
photo_url?: string;
|
||||
auth_date: number;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
onTelegramAuth: (user: any) => void;
|
||||
onTelegramAuth: (user: TelegramAuthUser) => void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,14 +68,15 @@ export default function AuthClient() {
|
||||
const t = DICT[lang];
|
||||
|
||||
const [status, setStatus] = useState<
|
||||
"processing" | "granted" | "denied" | "awaiting"
|
||||
"processing" | "granted" | "denied" | "awaiting" | "ghost_login"
|
||||
>("processing");
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [seedInput, setSeedInput] = useState("");
|
||||
const tgContainerRef = useRef<HTMLDivElement>(null);
|
||||
const initialized = useRef(false);
|
||||
const widgetInjected = useRef(false);
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || "/api";
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1";
|
||||
const BOT_USERNAME = import.meta.env.VITE_BOT_USERNAME || "ntrnr_vpn_bot";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -110,7 +122,7 @@ export default function AuthClient() {
|
||||
if (!token) {
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
"> [WAITING] INITIALIZE TELEGRAM SECURE LOGIN...",
|
||||
"> [WAITING] INITIALIZE SECURE LOGIN...",
|
||||
]);
|
||||
setStatus("awaiting");
|
||||
return;
|
||||
@@ -120,7 +132,7 @@ export default function AuthClient() {
|
||||
setLogs((prev) => [...prev, `> ${t.verifying}`]);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/me`, {
|
||||
const res = await fetch(`${API_BASE}/users/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
@@ -135,7 +147,7 @@ export default function AuthClient() {
|
||||
setLogs((prev) => [...prev, "> [FAIL] API REJECTED TOKEN"]);
|
||||
setStatus("awaiting");
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setLogs((prev) => [...prev, "> [FAIL] CORE OFFLINE"]);
|
||||
setStatus("denied");
|
||||
}
|
||||
@@ -145,7 +157,7 @@ export default function AuthClient() {
|
||||
}, [t, navigate, API_BASE]);
|
||||
|
||||
useEffect(() => {
|
||||
window.onTelegramAuth = async (user: any) => {
|
||||
window.onTelegramAuth = async (user: TelegramAuthUser) => {
|
||||
setStatus("processing");
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
@@ -174,7 +186,7 @@ export default function AuthClient() {
|
||||
setLogs((prev) => [...prev, "> [FAIL] INVALID SIGNATURE OR EXPIRED"]);
|
||||
setStatus("denied");
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setLogs((prev) => [...prev, "> [FAIL] NETWORK ERROR"]);
|
||||
setStatus("denied");
|
||||
}
|
||||
@@ -199,6 +211,66 @@ export default function AuthClient() {
|
||||
}
|
||||
}, [status, BOT_USERNAME]);
|
||||
|
||||
const handleGhostGenerate = async () => {
|
||||
setStatus("processing");
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
"> EXECUTING GHOST PROTOCOL...",
|
||||
"> GENERATING SECURE SEED...",
|
||||
]);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/seed/generate`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
localStorage.setItem("nrxp_token", data.token);
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
`> [CRITICAL] YOUR SEED: ${data.seed}`,
|
||||
"> SAVE THIS SEED. IT CANNOT BE RECOVERED.",
|
||||
"> ROUTING TO UPLINK...",
|
||||
]);
|
||||
setStatus("granted");
|
||||
// Задержка больше, чтобы юзер успел скопировать сид
|
||||
setTimeout(() => navigate("/profile"), 6000);
|
||||
} else throw new Error();
|
||||
} catch {
|
||||
setLogs((prev) => [...prev, "> [FAIL] GENERATION FAILED"]);
|
||||
setStatus("denied");
|
||||
}
|
||||
};
|
||||
|
||||
const handleGhostLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setStatus("processing");
|
||||
setLogs((prev) => [...prev, "> VERIFYING GHOST SEED..."]);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/seed/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ seed: seedInput }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
localStorage.setItem("nrxp_token", data.token);
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
"> [OK] SEED ACCEPTED",
|
||||
"> ROUTING TO UPLINK...",
|
||||
]);
|
||||
setStatus("granted");
|
||||
setTimeout(() => navigate("/profile"), 1500);
|
||||
} else {
|
||||
setLogs((prev) => [...prev, "> [FAIL] INVALID SEED"]);
|
||||
setTimeout(() => setStatus("ghost_login"), 2000);
|
||||
}
|
||||
} catch {
|
||||
setLogs((prev) => [...prev, "> [FAIL] NETWORK ERROR"]);
|
||||
setStatus("denied");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pt-16 pb-24 px-4 container mx-auto max-w-2xl relative flex flex-col items-center justify-center font-mono bg-[#05050A]">
|
||||
{/* Мягкое фоновое свечение */}
|
||||
@@ -230,7 +302,7 @@ export default function AuthClient() {
|
||||
<ShieldAlert className="w-20 h-20 text-red-500 drop-shadow-[0_0_15px_rgba(255,0,0,0.8)] relative z-10" />
|
||||
</div>
|
||||
)}
|
||||
{status === "awaiting" && (
|
||||
{(status === "awaiting" || status === "ghost_login") && (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-secondary/20 blur-xl rounded-full" />
|
||||
<Terminal className="w-20 h-20 text-secondary drop-shadow-[0_0_15px_rgba(0,229,242,0.8)] relative z-10" />
|
||||
@@ -242,14 +314,14 @@ export default function AuthClient() {
|
||||
? t.title
|
||||
: status === "granted"
|
||||
? t.granted
|
||||
: status === "awaiting"
|
||||
? t.awaiting
|
||||
: t.denied}
|
||||
: status === "denied"
|
||||
? t.denied
|
||||
: t.awaiting}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Терминал с логами процесса авторизации */}
|
||||
<div className="bg-black/60 border border-white/10 rounded-2xl p-6 font-mono text-sm text-muted-foreground/80 min-h-[140px] flex flex-col justify-end space-y-3 mb-8 shadow-inner">
|
||||
<div className="bg-black/60 border border-white/10 rounded-2xl p-6 font-mono text-sm text-muted-foreground/80 min-h-[140px] flex flex-col justify-end space-y-3 mb-8 shadow-inner overflow-y-auto">
|
||||
<AnimatePresence>
|
||||
{logs.map((log, i) => (
|
||||
<motion.div
|
||||
@@ -261,7 +333,9 @@ export default function AuthClient() {
|
||||
? "text-secondary font-bold drop-shadow-[0_0_5px_rgba(0,229,242,0.5)]"
|
||||
: log.includes("[FAIL]")
|
||||
? "text-red-500 font-bold drop-shadow-[0_0_5px_rgba(255,0,0,0.5)]"
|
||||
: ""
|
||||
: log.includes("[CRITICAL]")
|
||||
? "text-primary font-bold animate-pulse"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{log}
|
||||
@@ -280,6 +354,73 @@ export default function AuthClient() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Блок логина виджетом Telegram */}
|
||||
{status === "awaiting" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col items-center text-center gap-4"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed max-w-md">
|
||||
{t.authPrompt}
|
||||
</p>
|
||||
<div
|
||||
ref={tgContainerRef}
|
||||
className="min-h-[40px] flex items-center justify-center bg-white/5 rounded-xl p-4 w-full"
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 w-full mt-4">
|
||||
<button
|
||||
onClick={() => setStatus("ghost_login")}
|
||||
className="flex-1 bg-white/5 hover:bg-white/10 border border-white/10 text-muted-foreground hover:text-white p-3 rounded-xl transition-colors text-xs font-bold uppercase tracking-widest flex items-center justify-center gap-2"
|
||||
>
|
||||
<Key className="w-4 h-4" /> Seed Login
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGhostGenerate}
|
||||
className="flex-1 bg-primary/10 hover:bg-primary/20 border border-primary/30 text-primary p-3 rounded-xl transition-colors text-xs font-bold uppercase tracking-widest"
|
||||
>
|
||||
Create Seed
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Форма входа по Seed (Ghost Protocol) */}
|
||||
{status === "ghost_login" && (
|
||||
<motion.form
|
||||
onSubmit={handleGhostLogin}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
value={seedInput}
|
||||
onChange={(e) => setSeedInput(e.target.value)}
|
||||
placeholder="Enter 16-digit Seed"
|
||||
className="w-full bg-black/50 border border-white/10 rounded-xl p-4 text-center font-mono text-lg text-white focus:border-secondary outline-none transition-colors tracking-[0.2em]"
|
||||
maxLength={16}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus("awaiting")}
|
||||
className="flex-1 bg-white/5 text-muted-foreground p-3 rounded-xl text-xs uppercase tracking-widest hover:text-white transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-[2] bg-secondary/10 border border-secondary/30 text-secondary font-bold p-3 rounded-xl text-xs uppercase tracking-widest hover:bg-secondary/20 transition-colors"
|
||||
>
|
||||
Authenticate
|
||||
</button>
|
||||
</div>
|
||||
</motion.form>
|
||||
)}
|
||||
|
||||
{/* Сообщение об ошибке и кнопка в бот (если доступ запрещен) */}
|
||||
{status === "denied" && (
|
||||
<motion.div
|
||||
@@ -304,24 +445,7 @@ export default function AuthClient() {
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Блок логина виджетом Telegram (рендерится только если нет токена) */}
|
||||
{status === "awaiting" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col items-center text-center gap-6 mt-4"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed max-w-md">
|
||||
{t.authPrompt}
|
||||
</p>
|
||||
<div
|
||||
ref={tgContainerRef}
|
||||
className="min-h-[40px] flex items-center justify-center bg-white/5 rounded-xl p-4 w-full"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+119
-61
@@ -15,35 +15,56 @@ import {
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
TonConnectButton,
|
||||
useTonConnectUI,
|
||||
useTonAddress,
|
||||
} from "@tonconnect/ui-react";
|
||||
import { fetchProfile, activateTrial, initiatePayment, confirmPayment } from "./api";
|
||||
import { beginCell } from "@ton/core";
|
||||
|
||||
// Mock API functions for standalone preview compilation
|
||||
const fetchProfile = async () => {
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
user: {
|
||||
tg_id: "0x1A4F",
|
||||
tg_username: "GhostRunner",
|
||||
balance: 8450,
|
||||
game_tickets: 5,
|
||||
has_used_trial: false,
|
||||
},
|
||||
subscription: {
|
||||
plan_name: "NETRUNNER",
|
||||
expires_at: new Date(
|
||||
Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
},
|
||||
recruits: 12,
|
||||
earned: 1200,
|
||||
}),
|
||||
1500,
|
||||
),
|
||||
);
|
||||
};
|
||||
const activateTrial = async () => Promise.resolve();
|
||||
const buySubscription = async (plan: string) => Promise.resolve(plan);
|
||||
// --- ТИПИЗАЦИЯ ---
|
||||
|
||||
interface SubscriptionData {
|
||||
plan_name: string;
|
||||
expires_at: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface ReferralData {
|
||||
user_id: string;
|
||||
status: string;
|
||||
total_earned: number;
|
||||
}
|
||||
|
||||
interface ProfileDataResponse {
|
||||
user?: ProfileDataResponse; // Для поддержки старого мока
|
||||
subscription?: SubscriptionData;
|
||||
referrals?: ReferralData[];
|
||||
recruits?: number;
|
||||
earned?: number;
|
||||
// Плоские свойства с бэкенда
|
||||
id?: string;
|
||||
tg_id?: string | number;
|
||||
username?: string;
|
||||
tg_username?: string;
|
||||
role?: string;
|
||||
balance?: number;
|
||||
game_tickets?: number;
|
||||
has_used_trial?: boolean;
|
||||
}
|
||||
|
||||
interface UIPlan {
|
||||
name: string;
|
||||
price: number;
|
||||
price_usd: number;
|
||||
price_rub: number;
|
||||
}
|
||||
|
||||
// Вспомогательная функция снаружи компонента, чтобы обойти правило react-hooks/purity
|
||||
const getTonTxValidUntil = () => Math.floor(Date.now() / 1000) + 360;
|
||||
|
||||
// --- СЛОВАРИ И КОНСТАНТЫ ---
|
||||
|
||||
const DICT = {
|
||||
en: {
|
||||
@@ -138,7 +159,7 @@ const DICT = {
|
||||
},
|
||||
};
|
||||
|
||||
const UI_PLANS = [
|
||||
const UI_PLANS: UIPlan[] = [
|
||||
{ name: "TRIAL", price: 0, price_usd: 0, price_rub: 0 },
|
||||
{ name: "GHOST", price: 5, price_usd: 5, price_rub: 500 },
|
||||
{ name: "NETRUNNER", price: 9, price_usd: 9, price_rub: 900 },
|
||||
@@ -165,7 +186,7 @@ const itemVariants: Variants = {
|
||||
};
|
||||
|
||||
export default function Profile() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [data, setData] = useState<ProfileDataResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -174,14 +195,19 @@ export default function Profile() {
|
||||
|
||||
const [lang, setLang] = useState<"en" | "ru">(getLang);
|
||||
const t = DICT[lang];
|
||||
const botUsername = "NetrunnerBot";
|
||||
const botUsername = import.meta.env.VITE_BOT_USERNAME || "ntrnr_vpn_bot";
|
||||
|
||||
// TON Connect Hooks
|
||||
const [tonConnectUI] = useTonConnectUI();
|
||||
const userFriendlyAddress = useTonAddress();
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile()
|
||||
.then((res) => setData(res))
|
||||
.then((res: ProfileDataResponse) => setData(res))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setError(err.message || "Failed to connect to Netrunner Core");
|
||||
const e = err as Error;
|
||||
console.error(e);
|
||||
setError(e.message || "Failed to connect to Netrunner Core");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
@@ -208,8 +234,6 @@ export default function Profile() {
|
||||
);
|
||||
}
|
||||
|
||||
let currentData = data;
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-[#05050A] text-red-500 font-mono p-6 text-center">
|
||||
@@ -220,17 +244,17 @@ export default function Profile() {
|
||||
[ CONNECTION REFUSED ]
|
||||
</h1>
|
||||
<p className="text-xs opacity-70 max-w-xs mt-2 text-muted-foreground">
|
||||
Handshake failed. Authenticate via secure Telegram terminal.
|
||||
Handshake failed. Authenticate via secure terminal.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const user = currentData.user || {};
|
||||
const subscription = currentData.subscription;
|
||||
const user = data.user || data;
|
||||
const subscription = data.subscription;
|
||||
const hasSub = subscription && subscription.plan_name;
|
||||
const recruits = currentData.recruits || 0;
|
||||
const earned = currentData.earned || 0;
|
||||
const recruits = data.referrals ? data.referrals.length : (data.recruits || 0);
|
||||
const earned = data.earned || 0;
|
||||
|
||||
const copyRef = () => {
|
||||
navigator.clipboard.writeText(
|
||||
@@ -250,24 +274,55 @@ export default function Profile() {
|
||||
await activateTrial();
|
||||
const newData = await fetchProfile();
|
||||
setData(newData);
|
||||
} catch (e: any) {
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
alert(`[SYS.ERR] ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuy = async (planName: string) => {
|
||||
const executeTonPayment = async (planName: string) => {
|
||||
if (!userFriendlyAddress) {
|
||||
tonConnectUI.openModal();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await buySubscription(planName);
|
||||
const targetCurrency = lang === "ru" ? "RUB" : "USD";
|
||||
const { checkout_data } = await initiatePayment(planName, "TON", targetCurrency);
|
||||
|
||||
// Упаковываем комментарий (ID инвойса) в формат Cell для TON блокчейна
|
||||
const body = beginCell()
|
||||
.storeUint(0, 32) // 32 нуля означают, что дальше идет обычный текстовый комментарий
|
||||
.storeStringTail(checkout_data.comment)
|
||||
.endCell();
|
||||
|
||||
const payload = body.toBoc().toString("base64");
|
||||
|
||||
const transaction = {
|
||||
validUntil: getTonTxValidUntil(),
|
||||
messages: [
|
||||
{
|
||||
address: checkout_data.destination,
|
||||
amount: checkout_data.amount.toString(),
|
||||
payload: payload, // <-- Теперь блокчейн запишет ID инвойса в транзакцию!
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = await tonConnectUI.sendTransaction(transaction);
|
||||
await confirmPayment(checkout_data.comment, result.boc);
|
||||
|
||||
const newData = await fetchProfile();
|
||||
setData(newData);
|
||||
} catch (e: any) {
|
||||
alert(`[SYS.ERR] ${e.message}`);
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
console.error(e);
|
||||
alert(`[SYS.ERR] TRANSACTION ABORTED: ${e.message || "Network error"}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#05050A] p-4 sm:p-6 pb-24 text-foreground font-mono relative overflow-hidden">
|
||||
{/* Cyberpunk Grid Background */}
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none z-0 opacity-20"
|
||||
style={{
|
||||
@@ -292,12 +347,17 @@ export default function Profile() {
|
||||
Netrunner<span className="text-primary opacity-80">_OS</span>
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleLang}
|
||||
className="bg-black/40 border border-white/10 p-2.5 rounded-lg text-muted-foreground hover:text-secondary hover:border-secondary/40 transition-colors backdrop-blur-md"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="scale-90 origin-right">
|
||||
<TonConnectButton />
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleLang}
|
||||
className="bg-black/40 border border-white/10 p-2.5 rounded-lg text-muted-foreground hover:text-secondary hover:border-secondary/40 transition-colors backdrop-blur-md"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<motion.div
|
||||
@@ -306,7 +366,6 @@ export default function Profile() {
|
||||
animate="show"
|
||||
className="grid gap-5 max-w-2xl mx-auto relative z-10"
|
||||
>
|
||||
{/* User Stats Card */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="bg-white/[0.02] border border-white/10 rounded-xl p-5 backdrop-blur-md relative overflow-hidden"
|
||||
@@ -318,7 +377,7 @@ export default function Profile() {
|
||||
<Cpu className="w-3 h-3" /> {t.operative}
|
||||
</p>
|
||||
<p className="text-sm font-bold text-white/90 uppercase">
|
||||
@{user.tg_username || user.tg_id || "Anon"}
|
||||
@{user.username || user.tg_username || user.tg_id || "Anon"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -369,7 +428,6 @@ export default function Profile() {
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Breach Protocol Action */}
|
||||
<motion.button
|
||||
variants={itemVariants}
|
||||
onClick={() => navigate("/hack")}
|
||||
@@ -382,7 +440,6 @@ export default function Profile() {
|
||||
</span>
|
||||
</motion.button>
|
||||
|
||||
{/* System Override / Subscriptions */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="bg-white/[0.02] border border-white/10 rounded-xl p-5 backdrop-blur-md"
|
||||
@@ -411,7 +468,7 @@ export default function Profile() {
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="grid gap-3 mt-5 pb-1">
|
||||
{UI_PLANS.map((plan: any) => {
|
||||
{UI_PLANS.map((plan: UIPlan) => {
|
||||
const planData = t.plans[
|
||||
plan.name as keyof typeof t.plans
|
||||
] || { tag: "", desc: "", features: [] };
|
||||
@@ -463,7 +520,9 @@ export default function Profile() {
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
isTrial ? handleTrial() : handleBuy(plan.name)
|
||||
isTrial
|
||||
? handleTrial()
|
||||
: executeTonPayment(plan.name)
|
||||
}
|
||||
disabled={isTrialUsed}
|
||||
className={`mt-auto w-full py-2.5 rounded-lg text-[10px] font-bold uppercase tracking-widest transition-all
|
||||
@@ -490,7 +549,6 @@ export default function Profile() {
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Syndicate Network */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="bg-white/[0.02] border border-white/10 rounded-xl p-5 relative overflow-hidden group backdrop-blur-md"
|
||||
@@ -536,4 +594,4 @@ export default function Profile() {
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+26
-5
@@ -47,20 +47,41 @@ export const activateTrial = async () => {
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const buySubscription = async (plan: string) => {
|
||||
|
||||
export const initiatePayment = async (plan: string, provider: string, currency: string) => {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
// Путь: /api/v1/billing/subscribe
|
||||
const res = await fetch(`${API_BASE}/billing/subscribe`, {
|
||||
if (!token) throw new Error("No token found");
|
||||
|
||||
const res = await fetch(`${API_BASE}/billing/pay/initiate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ plan }),
|
||||
body: JSON.stringify({ plan, provider, currency }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || res.statusText || "Purchase failed");
|
||||
throw new Error(errData.error || "Initiate payment failed");
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const confirmPayment = async (invoice_id: string, external_tx_id: string) => {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
const res = await fetch(`${API_BASE}/billing/pay/confirm`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ invoice_id, external_tx_id }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || "Payment verification failed");
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
Reference in New Issue
Block a user