[AI] fix some modules, develop the big part of modules. TODO fix critical errors, update pipeline, test everything

This commit is contained in:
2026-05-16 19:16:50 +07:00
parent 0824033602
commit 1ef93c2025
42 changed files with 2780 additions and 786 deletions
+38 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
};