818d4fa384
Auth.tsx and Profile.tsx read import.meta.env.VITE_BOT_USERNAME — a Vite build-time constant. This ЛК frontend is a single static bundle built once in CI and served identically by both prod and dev (no server-rendering to re-inject a value per environment, unlike the Next.js landing fix from earlier). So the Telegram Login Widget on the dev stand was rendering with whatever bot the CI build happened to be compiled with — in practice the fallback default, the prod bot's username — and Telegram correctly refused it with "Bot domain invalid" since that bot's registered domain is the prod domain, not dev.netrunner-vpn.com. Added GET /api/v1/config (unauthenticated, reads BOT_USERNAME from the server's actual environment on every request) and switched both files to fetch it at runtime instead of reading the baked-in env var. Verified locally: the dev container's /api/v1/config correctly returns its own dev bot's username, and a real headless Chrome run confirms the Login Widget script tag gets that value, not the hardcoded fallback. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
605 lines
23 KiB
TypeScript
605 lines
23 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { motion, AnimatePresence, type Variants } from "framer-motion";
|
|
import {
|
|
Shield,
|
|
Zap,
|
|
Copy,
|
|
Loader2,
|
|
ArrowRight,
|
|
Users,
|
|
Activity,
|
|
CheckCircle2,
|
|
Globe,
|
|
Terminal,
|
|
Cpu,
|
|
Database,
|
|
} from "lucide-react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import {
|
|
TonConnectButton,
|
|
useTonConnectUI,
|
|
useTonAddress,
|
|
} from "@tonconnect/ui-react";
|
|
import {
|
|
fetchProfile,
|
|
activateTrial,
|
|
initiatePayment,
|
|
confirmPayment,
|
|
getPublicConfig,
|
|
} from "./api";
|
|
|
|
// --- ТИПИЗАЦИЯ ---
|
|
|
|
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: {
|
|
auth_ready: "SYSTEM ONLINE",
|
|
operative: "Operative ID",
|
|
balance: "Credits",
|
|
tickets: "Access Tokens",
|
|
uplink_status: "Node Status",
|
|
expires: "Valid Until",
|
|
offline: "DISCONNECTED",
|
|
no_active_sub: "No active grid connection.",
|
|
run_breach: "Execute Breach Protocol",
|
|
system_override: "Sys.Override()",
|
|
manage_plan: "Manage Uplink",
|
|
acquire: "Acquire",
|
|
activate_trial: "Initialize Trial",
|
|
trial_used: "Trial Exhausted",
|
|
syndicate: "Syndicate Grid",
|
|
recruits: "Nodes Hacked",
|
|
earned: "Eddies Extracted",
|
|
ref_desc: "Expand the network. Extract 100 E$ per initialized node.",
|
|
copied: "Data copied to clipboard.",
|
|
plans: {
|
|
TRIAL: {
|
|
tag: "Trial Run",
|
|
desc: "Experience the Grid. 3 days of unrestricted access.",
|
|
features: ["5GB Bandwidth", "No logs", "Standard Nodes"],
|
|
},
|
|
GHOST: {
|
|
tag: "Stealth Node",
|
|
desc: "The ideal choice for personal use. Minimal digital footprint.",
|
|
features: ["1 device", "ChaCha20-Poly1305", "DPI Protection"],
|
|
},
|
|
NETRUNNER: {
|
|
tag: "Pro Interface",
|
|
desc: "For professionals working with multiple rigs simultaneously.",
|
|
features: ["5 devices", "Priority Support", "FakeDNS Module"],
|
|
},
|
|
MAINFRAME: {
|
|
tag: "Command Center",
|
|
desc: "The ultimate solution for teams and maximum network independence.",
|
|
features: [
|
|
"Unlimited devices",
|
|
"High-Load Ready",
|
|
"24/7 Priority Support",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
ru: {
|
|
auth_ready: "СИСТЕМА АКТИВНА",
|
|
operative: "ID Оперативника",
|
|
balance: "Кредиты",
|
|
tickets: "Токены Доступа",
|
|
uplink_status: "Статус Узла",
|
|
expires: "Действует до",
|
|
offline: "ОТКЛЮЧЕНО",
|
|
no_active_sub: "Нет активного подключения к Сети.",
|
|
run_breach: "Протокол Взлома",
|
|
system_override: "Sys.Override()",
|
|
manage_plan: "Управление Узлом",
|
|
acquire: "Интегрировать",
|
|
activate_trial: "Запуск Триала",
|
|
trial_used: "Триал Использован",
|
|
syndicate: "Синдикат",
|
|
recruits: "Взломано узлов",
|
|
earned: "Извлечено (E$)",
|
|
ref_desc: "Расширяй сеть. Извлекай 100 E$ за каждый новый узел.",
|
|
copied: "Данные скопированы.",
|
|
plans: {
|
|
TRIAL: {
|
|
tag: "Тестовый Прогон",
|
|
desc: "Испытай Сеть. 3 дня безлимитного доступа.",
|
|
features: ["5GB Трафика", "Без логов", "Стандартные узлы"],
|
|
},
|
|
GHOST: {
|
|
tag: "Стелс-Узел",
|
|
desc: "Идеально для себя. Минимальный цифровой след.",
|
|
features: ["1 устройство", "ChaCha20-Poly1305", "Защита от DPI"],
|
|
},
|
|
NETRUNNER: {
|
|
tag: "Про-Интерфейс",
|
|
desc: "Для работы со смартфоном, декой и планшетом одновременно.",
|
|
features: ["5 устройств", "Приоритетная база", "Модуль FakeDNS"],
|
|
},
|
|
MAINFRAME: {
|
|
tag: "Командный Центр",
|
|
desc: "Ультимативное решение для команд и полной независимости.",
|
|
features: ["Безлимит устройств", "High-Load сервера", "Поддержка 24/7"],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
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 },
|
|
{ name: "MAINFRAME", price: 20, price_usd: 20, price_rub: 2000 },
|
|
];
|
|
|
|
const getLang = (): "en" | "ru" => {
|
|
const l = localStorage.getItem("lang");
|
|
return l === "ru" ? "ru" : "en";
|
|
};
|
|
|
|
// Анимационные варианты для элементов HUD
|
|
const containerVariants: Variants = {
|
|
hidden: { opacity: 0 },
|
|
show: {
|
|
opacity: 1,
|
|
transition: { staggerChildren: 0.1 },
|
|
},
|
|
};
|
|
|
|
const itemVariants: Variants = {
|
|
hidden: { opacity: 0, y: 10 },
|
|
show: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 100 } },
|
|
};
|
|
|
|
export default function Profile() {
|
|
const [data, setData] = useState<ProfileDataResponse | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
const [showPlans, setShowPlans] = useState(false);
|
|
const navigate = useNavigate();
|
|
|
|
const [lang, setLang] = useState<"en" | "ru">(getLang);
|
|
const t = DICT[lang];
|
|
const [botUsername, setBotUsername] = useState("ntrnr_vpn_bot");
|
|
|
|
// TON Connect Hooks
|
|
const [tonConnectUI] = useTonConnectUI();
|
|
const userFriendlyAddress = useTonAddress();
|
|
|
|
useEffect(() => {
|
|
getPublicConfig().then((cfg) => setBotUsername(cfg.bot_username));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchProfile()
|
|
.then((res: ProfileDataResponse) => setData(res))
|
|
.catch((err) => {
|
|
const e = err as Error;
|
|
console.error(e);
|
|
setError(e.message || "Failed to connect to Netrunner Core");
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
const toggleLang = () => {
|
|
const next = lang === "en" ? "ru" : "en";
|
|
setLang(next);
|
|
localStorage.setItem("lang", next);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen flex flex-col items-center justify-center bg-[#05050A] text-secondary font-mono">
|
|
<div className="relative flex items-center justify-center mb-6">
|
|
<div className="absolute inset-0 border-t-2 border-secondary rounded-full animate-spin w-16 h-16 opacity-50" />
|
|
<Loader2 className="w-8 h-8 animate-spin" />
|
|
</div>
|
|
<div className="typewriter">
|
|
<p className="tracking-[0.3em] uppercase text-xs animate-pulse opacity-80">
|
|
Establishing secure uplink...
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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">
|
|
<div className="bg-red-500/10 p-4 rounded-full mb-6 border border-red-500/30">
|
|
<Shield className="w-12 h-12 text-red-500" />
|
|
</div>
|
|
<h1 className="text-xl font-black tracking-widest mb-2 uppercase">
|
|
[ CONNECTION REFUSED ]
|
|
</h1>
|
|
<p className="text-xs opacity-70 max-w-xs mt-2 text-muted-foreground">
|
|
Handshake failed. Authenticate via secure terminal.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const user = data.user || data;
|
|
const subscription = data.subscription;
|
|
const hasSub = subscription && subscription.plan_name;
|
|
const recruits = data.referrals ? data.referrals.length : data.recruits || 0;
|
|
const earned = data.earned || 0;
|
|
|
|
const copyRef = () => {
|
|
navigator.clipboard.writeText(
|
|
`https://t.me/${botUsername}?start=${user.tg_id || user.id}`,
|
|
);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
};
|
|
|
|
const getPrice = (usd: number, rub: number) => {
|
|
if (usd === 0) return "FREE";
|
|
return lang === "ru" ? `${rub} ₽/мес` : `$${usd}/mo`;
|
|
};
|
|
|
|
const handleTrial = async () => {
|
|
try {
|
|
await activateTrial();
|
|
const newData = await fetchProfile();
|
|
setData(newData);
|
|
} catch (err) {
|
|
const e = err as Error;
|
|
alert(`[SYS.ERR] ${e.message}`);
|
|
}
|
|
};
|
|
|
|
const executeTonPayment = async (planName: string) => {
|
|
if (!userFriendlyAddress) {
|
|
tonConnectUI.openModal();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const targetCurrency = lang === "ru" ? "RUB" : "USD";
|
|
// Бэкенд возвращает destination, amount и comment
|
|
const { checkout_data } = await initiatePayment(
|
|
planName,
|
|
"TON",
|
|
targetCurrency,
|
|
);
|
|
|
|
const transaction = {
|
|
validUntil: getTonTxValidUntil(),
|
|
messages: [
|
|
{
|
|
address: checkout_data.destination,
|
|
amount: checkout_data.amount.toString(),
|
|
// Кошелек сам упакует комментарий, если его передать в payload как text:
|
|
payload: `text:${checkout_data.comment}`,
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = await tonConnectUI.sendTransaction(transaction);
|
|
// Отправляем результат (boc) на бэкенд для верификации
|
|
await confirmPayment(checkout_data.comment, result.boc);
|
|
|
|
const newData = await fetchProfile();
|
|
setData(newData);
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert(`[SYS.ERR] TRANSACTION ABORTED`);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#05050A] p-4 sm:p-6 pb-24 text-foreground font-mono relative overflow-hidden">
|
|
<div
|
|
className="fixed inset-0 pointer-events-none z-0 opacity-20"
|
|
style={{
|
|
backgroundImage: `
|
|
linear-gradient(to right, rgba(255,255,255,0.05) 1px, transparent 1px),
|
|
linear-gradient(to bottom, rgba(255,255,255,0.05) 1px, transparent 1px)
|
|
`,
|
|
backgroundSize: "40px 40px",
|
|
maskImage:
|
|
"radial-gradient(ellipse at 50% 0%, black 10%, transparent 80%)",
|
|
WebkitMaskImage:
|
|
"radial-gradient(ellipse at 50% 0%, black 10%, transparent 80%)",
|
|
}}
|
|
/>
|
|
|
|
<header className="flex justify-between items-start mb-8 relative z-10 pt-2">
|
|
<div>
|
|
<div className="inline-flex items-center gap-2 text-secondary text-[10px] font-bold uppercase tracking-widest mb-1 opacity-80">
|
|
<Activity className="w-3 h-3 animate-pulse" /> [{t.auth_ready}]
|
|
</div>
|
|
<h1 className="text-2xl font-black tracking-tight text-white/90 drop-shadow-[0_0_10px_rgba(255,255,255,0.1)] uppercase">
|
|
Netrunner<span className="text-primary opacity-80">_OS</span>
|
|
</h1>
|
|
</div>
|
|
<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
|
|
variants={containerVariants}
|
|
initial="hidden"
|
|
animate="show"
|
|
className="grid gap-5 max-w-2xl mx-auto relative z-10"
|
|
>
|
|
<motion.div
|
|
variants={itemVariants}
|
|
className="bg-white/[0.02] border border-white/10 rounded-xl p-5 backdrop-blur-md relative overflow-hidden"
|
|
>
|
|
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/10 rounded-full blur-[40px] -mr-10 -mt-10" />
|
|
<div className="flex justify-between items-start mb-6 relative z-10">
|
|
<div>
|
|
<p className="text-muted-foreground text-[10px] uppercase tracking-widest mb-1 flex items-center gap-1">
|
|
<Cpu className="w-3 h-3" /> {t.operative}
|
|
</p>
|
|
<p className="text-sm font-bold text-white/90 uppercase">
|
|
@{user.username || user.tg_username || user.tg_id || "Anon"}
|
|
</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-muted-foreground text-[10px] uppercase tracking-widest mb-1 flex justify-end items-center gap-1">
|
|
<Database className="w-3 h-3" /> {t.balance}
|
|
</p>
|
|
<p className="text-2xl font-black text-secondary drop-shadow-[0_0_10px_rgba(0,229,242,0.4)]">
|
|
{user.balance || 0}{" "}
|
|
<span className="text-xs opacity-60">E$</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-black/50 border border-white/5 rounded-lg p-3.5 flex justify-between items-center relative z-10">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={`w-2 h-2 rounded-full ${hasSub ? "bg-secondary animate-pulse shadow-[0_0_8px_rgba(0,229,242,0.8)]" : "bg-red-500"}`}
|
|
/>
|
|
<span className="text-xs uppercase tracking-widest text-muted-foreground">
|
|
{t.uplink_status}
|
|
</span>
|
|
</div>
|
|
{hasSub ? (
|
|
<div className="text-right">
|
|
<span className="text-xs font-bold uppercase text-primary">
|
|
[{subscription.plan_name}]
|
|
</span>
|
|
<p className="text-[9px] text-muted-foreground mt-0.5 tracking-widest uppercase">
|
|
{t.expires}:{" "}
|
|
{new Date(subscription.expires_at).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<span className="text-xs font-bold uppercase text-red-500/80">
|
|
[{t.offline}]
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-3 flex justify-between items-center text-[10px] uppercase tracking-widest text-muted-foreground px-1">
|
|
<span>
|
|
{t.tickets}:{" "}
|
|
<span className="text-white/70">{user.game_tickets || 0}</span>
|
|
</span>
|
|
{!hasSub && (
|
|
<span className="text-red-400/60">{t.no_active_sub}</span>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
|
|
<motion.button
|
|
variants={itemVariants}
|
|
onClick={() => navigate("/hack")}
|
|
className="w-full relative bg-primary/10 hover:bg-primary/20 border border-primary/40 text-primary font-bold tracking-widest uppercase p-4 rounded-xl transition-all flex justify-center items-center gap-3 shadow-[0_0_15px_rgba(139,61,255,0.1)] hover:shadow-[0_0_25px_rgba(139,61,255,0.3)] group overflow-hidden"
|
|
>
|
|
<div className="absolute inset-0 bg-[linear-gradient(90deg,transparent_0%,rgba(139,61,255,0.1)_50%,transparent_100%)] translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000" />
|
|
<Terminal className="w-5 h-5 group-hover:text-white transition-colors" />
|
|
<span className="group-hover:text-white transition-colors">
|
|
[ {t.run_breach} ]
|
|
</span>
|
|
</motion.button>
|
|
|
|
<motion.div
|
|
variants={itemVariants}
|
|
className="bg-white/[0.02] border border-white/10 rounded-xl p-5 backdrop-blur-md"
|
|
>
|
|
<div className="flex justify-between items-center mb-2">
|
|
<h3 className="font-bold text-sm uppercase tracking-widest text-white/90">
|
|
_ {t.system_override}
|
|
</h3>
|
|
<button
|
|
onClick={() => setShowPlans(!showPlans)}
|
|
className="text-[10px] flex items-center gap-1.5 bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/20 px-3 py-1.5 rounded-md uppercase tracking-widest transition-colors"
|
|
>
|
|
{t.manage_plan}{" "}
|
|
<ArrowRight
|
|
className={`w-3 h-3 transition-transform ${showPlans ? "rotate-90" : ""}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{showPlans && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: "auto", opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="grid gap-3 mt-5 pb-1">
|
|
{UI_PLANS.map((plan: UIPlan) => {
|
|
const planData = t.plans[
|
|
plan.name as keyof typeof t.plans
|
|
] || { tag: "", desc: "", features: [] };
|
|
const isTrial = plan.name === "TRIAL";
|
|
const isTrialUsed = isTrial && user.has_used_trial;
|
|
|
|
return (
|
|
<div
|
|
key={plan.name}
|
|
className={`bg-black/60 border rounded-xl p-4 flex flex-col transition-all duration-300 group relative overflow-hidden ${
|
|
isTrial
|
|
? isTrialUsed
|
|
? "border-white/5 opacity-50 grayscale"
|
|
: "border-secondary/40 shadow-[0_0_10px_rgba(0,229,242,0.1)] hover:border-secondary"
|
|
: "border-white/10 hover:border-primary/50"
|
|
}`}
|
|
>
|
|
<div className="absolute top-0 left-0 w-1 h-full bg-gradient-to-b from-transparent via-white/10 to-transparent group-hover:via-primary/50 transition-colors" />
|
|
|
|
<div className="flex justify-between items-start mb-3 ml-2">
|
|
<div>
|
|
<h4 className="font-bold text-primary uppercase text-sm tracking-widest mb-0.5">
|
|
{plan.name}
|
|
</h4>
|
|
<p className="text-[9px] uppercase tracking-widest text-secondary/80">
|
|
{planData.tag}
|
|
</p>
|
|
</div>
|
|
<p className="text-xl font-black text-white/90 tracking-tight">
|
|
{getPrice(
|
|
plan.price_usd || plan.price,
|
|
plan.price_rub || plan.price * 100,
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<p className="text-[11px] text-muted-foreground mb-4 ml-2 leading-relaxed">
|
|
{planData.desc}
|
|
</p>
|
|
|
|
<ul className="text-[10px] text-muted-foreground/80 space-y-2 mb-5 ml-2 uppercase tracking-wide">
|
|
{planData.features.map((f: string, i: number) => (
|
|
<li key={i} className="flex items-center gap-2">
|
|
<span className="text-secondary/50">{">"}</span>{" "}
|
|
{f}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<button
|
|
onClick={() =>
|
|
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
|
|
${
|
|
isTrialUsed
|
|
? "bg-white/5 text-muted-foreground cursor-not-allowed"
|
|
: isTrial
|
|
? "bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/30"
|
|
: "bg-white/5 hover:bg-primary/20 text-white border border-white/10 hover:border-primary/50"
|
|
}`}
|
|
>
|
|
{isTrial
|
|
? isTrialUsed
|
|
? t.trial_used
|
|
: t.activate_trial
|
|
: t.acquire}
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
variants={itemVariants}
|
|
className="bg-white/[0.02] border border-white/10 rounded-xl p-5 relative overflow-hidden group backdrop-blur-md"
|
|
>
|
|
<Zap className="absolute -right-4 -bottom-4 w-32 h-32 text-primary opacity-5 group-hover:opacity-[0.08] transition-opacity" />
|
|
<h3 className="font-bold text-sm mb-4 relative z-10 flex items-center gap-2 uppercase tracking-widest text-white/90">
|
|
<Users className="w-4 h-4 text-primary" /> _ {t.syndicate}
|
|
</h3>
|
|
|
|
<div className="grid grid-cols-2 gap-3 mb-5 relative z-10">
|
|
<div className="bg-black/50 border border-white/5 rounded-lg p-3 text-center hover:border-primary/20 transition-colors">
|
|
<p className="text-xl font-black text-white/90">{recruits}</p>
|
|
<p className="text-[9px] uppercase tracking-widest text-muted-foreground mt-1">
|
|
{t.recruits}
|
|
</p>
|
|
</div>
|
|
<div className="bg-black/50 border border-white/5 rounded-lg p-3 text-center hover:border-secondary/20 transition-colors">
|
|
<p className="text-xl font-black text-secondary">{earned}</p>
|
|
<p className="text-[9px] uppercase tracking-widest text-muted-foreground mt-1">
|
|
{t.earned}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-[10px] text-muted-foreground mb-3 max-w-[90%] relative z-10 uppercase tracking-wide">
|
|
{t.ref_desc}
|
|
</p>
|
|
|
|
<button
|
|
onClick={copyRef}
|
|
className="w-full relative z-10 bg-black/60 border border-white/10 hover:border-secondary/40 flex items-center justify-between p-3.5 rounded-lg transition-all"
|
|
>
|
|
<span className="text-[11px] text-muted-foreground truncate mr-4 font-mono">
|
|
t.me/{botUsername}?start={user.tg_id || user.id}
|
|
</span>
|
|
{copied ? (
|
|
<CheckCircle2 className="w-4 h-4 text-secondary shrink-0" />
|
|
) : (
|
|
<Copy className="w-4 h-4 text-secondary/60 hover:text-secondary shrink-0 transition-colors" />
|
|
)}
|
|
</button>
|
|
</motion.div>
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
}
|