1220 lines
40 KiB
TypeScript
1220 lines
40 KiB
TypeScript
import {
|
||
Box,
|
||
Typography,
|
||
Button,
|
||
Grid,
|
||
FormControl,
|
||
Select,
|
||
MenuItem,
|
||
InputLabel,
|
||
Tabs,
|
||
Tab,
|
||
TextField,
|
||
Dialog,
|
||
DialogActions,
|
||
DialogContent,
|
||
DialogTitle,
|
||
} from '@mui/material';
|
||
import {
|
||
Cape,
|
||
fetchCapes,
|
||
fetchCapesStore,
|
||
purchaseCape,
|
||
StoreCape,
|
||
Case,
|
||
CaseItem,
|
||
fetchCases,
|
||
fetchCase,
|
||
openCase,
|
||
Server,
|
||
fetchPlayer,
|
||
BonusType,
|
||
UserBonus,
|
||
fetchBonusTypes,
|
||
fetchUserBonuses,
|
||
purchaseBonus,
|
||
upgradeBonus,
|
||
toggleBonusActivation,
|
||
} from '../api';
|
||
import { useEffect, useState } from 'react';
|
||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||
import { getPlayerServer } from '../utils/playerOnlineCheck';
|
||
import CaseRoulette from '../components/CaseRoulette';
|
||
import BonusShopItem from '../components/BonusShopItem';
|
||
import ShopItem from '../components/ShopItem';
|
||
import { playBuySound, primeSounds } from '../utils/sounds';
|
||
import CustomNotification from '../components/Notifications/CustomNotification';
|
||
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||
import {
|
||
executePrank,
|
||
fetchPrankCommands,
|
||
fetchPrankServers,
|
||
PrankCommand,
|
||
PrankServer,
|
||
} from '../api/commands';
|
||
|
||
import {
|
||
isNotificationsEnabled,
|
||
getNotifPositionFromSettings,
|
||
} from '../utils/notifications';
|
||
import { translateServer } from '../utils/serverTranslator';
|
||
import { Server } from 'http';
|
||
|
||
interface TabPanelProps {
|
||
children?: React.ReactNode;
|
||
index: number;
|
||
value: number;
|
||
}
|
||
|
||
function TabPanel(props: TabPanelProps) {
|
||
const { children, value, index, ...other } = props;
|
||
|
||
return (
|
||
<div role="tabpanel" hidden={value !== index} {...other}>
|
||
{value === index && <Box>{children}</Box>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function getRarityByWeight(
|
||
weight?: number,
|
||
): 'common' | 'rare' | 'epic' | 'legendary' {
|
||
if (weight === undefined || weight === null) return 'common';
|
||
|
||
if (weight <= 5) return 'legendary';
|
||
if (weight <= 20) return 'epic';
|
||
if (weight <= 50) return 'rare';
|
||
return 'common';
|
||
}
|
||
|
||
function getRarityColor(weight?: number): string {
|
||
const rarity = getRarityByWeight(weight);
|
||
switch (rarity) {
|
||
case 'legendary':
|
||
return 'rgba(255, 215, 0, 1)'; // золотой
|
||
case 'epic':
|
||
return 'rgba(186, 85, 211, 1)'; // фиолетовый
|
||
case 'rare':
|
||
return 'rgba(65, 105, 225, 1)'; // синий
|
||
case 'common':
|
||
default:
|
||
return 'rgba(255, 255, 255, 0.25)'; // сероватый
|
||
}
|
||
}
|
||
|
||
const GRADIENT =
|
||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||
|
||
const GLASS_DIALOG_SX = {
|
||
borderRadius: '1.4vw',
|
||
background:
|
||
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.16), transparent 55%), rgba(10,10,20,0.92)',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
boxShadow: '0 1.6vw 4vw rgba(0,0,0,0.6)',
|
||
backdropFilter: 'blur(16px)',
|
||
};
|
||
|
||
export default function Shop() {
|
||
const [storeCapes, setStoreCapes] = useState<StoreCape[]>([]);
|
||
const [userCapes, setUserCapes] = useState<Cape[]>([]);
|
||
const [username, setUsername] = useState<string>('');
|
||
const [uuid, setUuid] = useState<string>('');
|
||
const [loading, setLoading] = useState<boolean>(false);
|
||
|
||
const [playerSkinUrl, setPlayerSkinUrl] = useState<string>('');
|
||
|
||
const [selectedCaseServerIp, setSelectedCaseServerIp] = useState<string>('');
|
||
|
||
// Уведомления
|
||
|
||
const [notifOpen, setNotifOpen] = useState(false);
|
||
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
|
||
const [notifSeverity, setNotifSeverity] = useState<
|
||
'success' | 'info' | 'warning' | 'error'
|
||
>('info');
|
||
|
||
const [notifPos, setNotifPos] = useState<NotificationPosition>({
|
||
vertical: 'bottom',
|
||
horizontal: 'center',
|
||
});
|
||
|
||
// Прокачка
|
||
|
||
const [bonusTypes, setBonusTypes] = useState<BonusType[]>([]);
|
||
const [userBonuses, setUserBonuses] = useState<UserBonus[]>([]);
|
||
const [bonusesLoading, setBonusesLoading] = useState<boolean>(false);
|
||
const [processingBonusIds, setProcessingBonusIds] = useState<string[]>([]);
|
||
|
||
// Кейсы
|
||
const [cases, setCases] = useState<Case[]>([]);
|
||
const [casesLoading, setCasesLoading] = useState<boolean>(false);
|
||
|
||
// Онлайн/сервер (по аналогии с Marketplace)
|
||
const [isOnline, setIsOnline] = useState<boolean>(false);
|
||
const [playerServer, setPlayerServer] = useState<Server | null>(null);
|
||
const [onlineCheckLoading, setOnlineCheckLoading] = useState<boolean>(true);
|
||
|
||
// Рулетка
|
||
const [isOpening, setIsOpening] = useState<boolean>(false);
|
||
const [selectedCase, setSelectedCase] = useState<Case | null>(null);
|
||
|
||
const [rouletteOpen, setRouletteOpen] = useState(false);
|
||
const [rouletteCaseItems, setRouletteCaseItems] = useState<CaseItem[]>([]);
|
||
const [rouletteReward, setRouletteReward] = useState<CaseItem | null>(null);
|
||
|
||
// TABS
|
||
const [tabValue, setTabValue] = useState<number>(0);
|
||
|
||
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
|
||
setTabValue(newValue);
|
||
};
|
||
|
||
//команды
|
||
|
||
const [prankCommands, setPrankCommands] = useState<PrankCommand[]>([]);
|
||
const [prankServers, setPrankServers] = useState<PrankServer[]>([]);
|
||
|
||
const [pranksLoading, setPranksLoading] = useState(false);
|
||
|
||
const [selectedPrankServer, setSelectedPrankServer] = useState<string>('');
|
||
const [targetPlayer, setTargetPlayer] = useState<string>('');
|
||
const [processingCommandId, setProcessingCommandId] = useState<string | null>(
|
||
null,
|
||
);
|
||
|
||
const [prankDialogOpen, setPrankDialogOpen] = useState(false);
|
||
const [selectedPrank, setSelectedPrank] = useState<PrankCommand | null>(null);
|
||
|
||
const [prankTarget, setPrankTarget] = useState('');
|
||
const [prankServerId, setPrankServerId] = useState<string>('');
|
||
const [prankProcessing, setPrankProcessing] = useState(false);
|
||
|
||
// Уведомления
|
||
const [notification, setNotification] = useState<{
|
||
open: boolean;
|
||
message: string;
|
||
type: 'success' | 'error';
|
||
}>({
|
||
open: false,
|
||
message: '',
|
||
type: 'success',
|
||
});
|
||
|
||
const showNotification = (
|
||
message: React.ReactNode,
|
||
severity: 'success' | 'info' | 'warning' | 'error' = 'info',
|
||
position: NotificationPosition = getNotifPositionFromSettings(),
|
||
) => {
|
||
if (!isNotificationsEnabled()) return;
|
||
setNotifMsg(message);
|
||
setNotifSeverity(severity);
|
||
setNotifPos(position);
|
||
setNotifOpen(true);
|
||
};
|
||
|
||
const loadBonuses = async (username: string) => {
|
||
try {
|
||
setBonusesLoading(true);
|
||
const [types, user] = await Promise.all([
|
||
fetchBonusTypes(),
|
||
fetchUserBonuses(username),
|
||
]);
|
||
setBonusTypes(types);
|
||
setUserBonuses(user);
|
||
} catch (error) {
|
||
console.error('Ошибка при получении прокачек:', error);
|
||
|
||
if (!isNotificationsEnabled()) return;
|
||
showNotification('Ошибка при загрузке прокачки!', 'error');
|
||
} finally {
|
||
setBonusesLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadPlayerSkin = async (uuid: string) => {
|
||
try {
|
||
const player = await fetchPlayer(uuid);
|
||
setPlayerSkinUrl(player.skin_url);
|
||
} catch (error) {
|
||
console.error('Ошибка при получении скина игрока:', error);
|
||
setPlayerSkinUrl('');
|
||
}
|
||
};
|
||
|
||
// Функция для загрузки плащей из магазина
|
||
const loadStoreCapes = async () => {
|
||
try {
|
||
const capes = await fetchCapesStore();
|
||
setStoreCapes(capes);
|
||
} catch (error) {
|
||
console.error('Ошибка при получении плащей магазина:', error);
|
||
setStoreCapes([]);
|
||
}
|
||
};
|
||
|
||
// Загрузка команд
|
||
useEffect(() => {
|
||
if (tabValue !== 3) return;
|
||
|
||
const load = async () => {
|
||
try {
|
||
setPranksLoading(true);
|
||
const [commands, servers] = await Promise.all([
|
||
fetchPrankCommands(),
|
||
fetchPrankServers(),
|
||
]);
|
||
setPrankCommands(commands);
|
||
setPrankServers(servers);
|
||
if (servers.length) setSelectedPrankServer(servers[0].id);
|
||
} catch (e) {
|
||
console.error(e);
|
||
showNotification('Ошибка загрузки пакостей', 'error');
|
||
} finally {
|
||
setPranksLoading(false);
|
||
}
|
||
};
|
||
|
||
load();
|
||
}, [tabValue]);
|
||
|
||
// Функция для загрузки плащей пользователя
|
||
const loadUserCapes = async (username: string) => {
|
||
try {
|
||
const userCapes = await fetchCapes(username);
|
||
setUserCapes(userCapes);
|
||
} catch (error) {
|
||
console.error('Ошибка при получении плащей пользователя:', error);
|
||
setUserCapes([]);
|
||
}
|
||
};
|
||
|
||
const handlePurchaseCape = async (cape_id: string) => {
|
||
try {
|
||
await purchaseCape(username, cape_id);
|
||
await loadUserCapes(username);
|
||
|
||
playBuySound();
|
||
|
||
if (!isNotificationsEnabled()) return;
|
||
showNotification('Плащ успешно куплен!', 'success');
|
||
} catch (error) {
|
||
console.error('Ошибка при покупке плаща:', error);
|
||
|
||
if (!isNotificationsEnabled()) return;
|
||
showNotification('Ошибка при покупке плаща!', 'error');
|
||
}
|
||
};
|
||
|
||
// Загрузка кейсов
|
||
const loadCases = async () => {
|
||
try {
|
||
setCasesLoading(true);
|
||
const casesData = await fetchCases();
|
||
setCases(casesData);
|
||
} catch (error) {
|
||
console.error('Ошибка при получении кейсов:', error);
|
||
setCases([]);
|
||
} finally {
|
||
setCasesLoading(false);
|
||
}
|
||
};
|
||
|
||
// Проверка онлайна игрока (по аналогии с Marketplace.tsx)
|
||
const checkPlayerStatus = async () => {
|
||
if (!username) return;
|
||
|
||
try {
|
||
setOnlineCheckLoading(true);
|
||
const { online, server } = await getPlayerServer(username);
|
||
setIsOnline(online);
|
||
setPlayerServer(server || null);
|
||
} catch (error) {
|
||
console.error('Ошибка при проверке онлайн-статуса:', error);
|
||
setIsOnline(false);
|
||
setPlayerServer(null);
|
||
} finally {
|
||
setOnlineCheckLoading(false);
|
||
}
|
||
};
|
||
|
||
// Загружаем базовые данные при монтировании
|
||
useEffect(() => {
|
||
const savedConfig = localStorage.getItem('launcher_config');
|
||
if (savedConfig) {
|
||
const config = JSON.parse(savedConfig);
|
||
if (config.uuid && config.username) {
|
||
setUsername(config.username);
|
||
setUuid(config.uuid);
|
||
|
||
setLoading(true);
|
||
|
||
Promise.all([
|
||
loadStoreCapes(),
|
||
loadUserCapes(config.username),
|
||
loadCases(),
|
||
loadPlayerSkin(config.uuid),
|
||
loadBonuses(config.username),
|
||
])
|
||
.catch((err) => console.error(err))
|
||
.finally(() => {
|
||
setLoading(false);
|
||
});
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
// Проверяем онлайн после того, как знаем username
|
||
useEffect(() => {
|
||
if (username) {
|
||
checkPlayerStatus();
|
||
}
|
||
}, [username]);
|
||
|
||
const withProcessing = async (id: string, fn: () => Promise<void>) => {
|
||
setProcessingBonusIds((prev) => [...prev, id]);
|
||
try {
|
||
await fn();
|
||
} finally {
|
||
setProcessingBonusIds((prev) => prev.filter((x) => x !== id));
|
||
}
|
||
};
|
||
|
||
const handlePurchaseBonus = async (bonusTypeId: string) => {
|
||
if (!username) {
|
||
if (!isNotificationsEnabled()) return;
|
||
showNotification(
|
||
'Не найдено имя игрока. Авторизируйтесь в лаунчере!',
|
||
'error',
|
||
);
|
||
return;
|
||
}
|
||
|
||
await withProcessing(bonusTypeId, async () => {
|
||
try {
|
||
const res = await purchaseBonus(username, bonusTypeId);
|
||
|
||
playBuySound();
|
||
|
||
await loadBonuses(username);
|
||
|
||
if (!isNotificationsEnabled()) return;
|
||
showNotification('Прокачка успешно куплена!', 'success');
|
||
} catch (error) {
|
||
console.error('Ошибка при покупке прокачки:', error);
|
||
|
||
if (!isNotificationsEnabled()) return;
|
||
showNotification('Ошибка при прокачке!', 'error');
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleUpgradeBonus = async (bonusId: string) => {
|
||
if (!username) return;
|
||
|
||
await withProcessing(bonusId, async () => {
|
||
try {
|
||
await upgradeBonus(username, bonusId);
|
||
|
||
await loadBonuses(username);
|
||
|
||
if (!isNotificationsEnabled()) return;
|
||
showNotification('Бонус улучшен!', 'success');
|
||
} catch (error) {
|
||
console.error('Ошибка при улучшении бонуса:', error);
|
||
|
||
if (!isNotificationsEnabled()) return;
|
||
showNotification('Ошибка при улучшении бонуса!', 'error');
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleToggleBonusActivation = async (bonusId: string) => {
|
||
if (!username) return;
|
||
|
||
await withProcessing(bonusId, async () => {
|
||
try {
|
||
await toggleBonusActivation(username, bonusId);
|
||
await loadBonuses(username);
|
||
} catch (error) {
|
||
console.error('Ошибка при переключении бонуса:', error);
|
||
if (!isNotificationsEnabled()) return;
|
||
showNotification('Ошибка при переключении бонуса!', 'error');
|
||
}
|
||
});
|
||
};
|
||
|
||
const caseServers = Array.from(
|
||
new Set((cases || []).flatMap((c) => c.server_ips || []).filter(Boolean)),
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (caseServers.length > 0) {
|
||
// если игрок онлайн — по умолчанию его сервер, если он есть в кейсах
|
||
const preferred =
|
||
playerServer?.ip && caseServers.includes(playerServer.ip)
|
||
? playerServer.ip
|
||
: caseServers[0];
|
||
|
||
setSelectedCaseServerIp(preferred);
|
||
}
|
||
}, [caseServers.length, playerServer?.ip]);
|
||
|
||
const filteredCases = (cases || []).filter((c) => {
|
||
const allowed = c.server_ips || [];
|
||
// если список пуст — значит кейс доступен везде
|
||
if (allowed.length === 0) return true;
|
||
|
||
// иначе — показываем только те, где выбранный сервер разрешён
|
||
return !!selectedCaseServerIp && allowed.includes(selectedCaseServerIp);
|
||
});
|
||
|
||
// Фильтруем плащи, которые уже куплены пользователем
|
||
const availableCapes = storeCapes.filter(
|
||
(storeCape) =>
|
||
!userCapes.some((userCape) => userCape.cape_id === storeCape.id),
|
||
);
|
||
|
||
const handleOpenCase = async (caseData: Case) => {
|
||
if (!username) {
|
||
if (!isNotificationsEnabled()) return;
|
||
showNotification(
|
||
'Не найдено имя игрока. Авторизуйтесь в лаунчере!',
|
||
'error',
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (!selectedCaseServerIp) {
|
||
if (!isNotificationsEnabled()) return;
|
||
showNotification('Выберите сервер для открытия кейса!', 'warning');
|
||
return;
|
||
}
|
||
|
||
const allowedIps = caseData.server_ips || [];
|
||
if (allowedIps.length > 0 && !allowedIps.includes(selectedCaseServerIp)) {
|
||
if (!isNotificationsEnabled()) return;
|
||
showNotification(
|
||
`Этот кейс доступен на: ${allowedIps
|
||
.map((ip) => translateServer(`Server ${ip}`))
|
||
.join(', ')}`,
|
||
'warning',
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (isOpening) return;
|
||
|
||
try {
|
||
setIsOpening(true);
|
||
|
||
const fullCase = await fetchCase(caseData.id);
|
||
const caseItems: CaseItem[] = fullCase.items || [];
|
||
setSelectedCase(fullCase);
|
||
|
||
// ✅ открываем на выбранном сервере (даже если игрок не на сервере)
|
||
const result = await openCase(
|
||
fullCase.id,
|
||
username,
|
||
selectedCaseServerIp,
|
||
);
|
||
|
||
setRouletteCaseItems(caseItems);
|
||
setRouletteReward(result.reward);
|
||
setRouletteOpen(true);
|
||
playBuySound();
|
||
|
||
if (!isNotificationsEnabled()) return;
|
||
showNotification('Кейс открыт!', 'success');
|
||
} catch (error) {
|
||
console.error('Ошибка при открытии кейса:', error);
|
||
|
||
if (!isNotificationsEnabled()) return;
|
||
showNotification(
|
||
String(
|
||
error instanceof Error ? error.message : 'Ошибка при открытии кейса!',
|
||
),
|
||
'error',
|
||
);
|
||
} finally {
|
||
setIsOpening(false);
|
||
}
|
||
};
|
||
|
||
const handleCloseNotification = () => {
|
||
setNotification((prev) => ({ ...prev, open: false }));
|
||
};
|
||
|
||
const handleCloseRoulette = () => {
|
||
setRouletteOpen(false);
|
||
};
|
||
|
||
useEffect(() => {
|
||
const onFirstUserGesture = () => primeSounds();
|
||
|
||
window.addEventListener('pointerdown', onFirstUserGesture, { once: true });
|
||
return () => window.removeEventListener('pointerdown', onFirstUserGesture);
|
||
}, []);
|
||
|
||
return (
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
gap: '2vw',
|
||
width: '100%',
|
||
height: '100%',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{(loading || onlineCheckLoading) && (
|
||
<FullScreenLoader message="Загрузка магазина..." />
|
||
)}
|
||
|
||
{!loading && !onlineCheckLoading && (
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
width: '100%',
|
||
height: '100%',
|
||
overflow: 'auto',
|
||
paddingBottom: '2w',
|
||
paddingLeft: '7vw',
|
||
paddingRight: '1.5vw',
|
||
gap: '2vw',
|
||
}}
|
||
>
|
||
<Tabs
|
||
value={tabValue}
|
||
onChange={handleTabChange}
|
||
disableRipple
|
||
sx={{
|
||
// minHeight: '3vw',
|
||
// mb: '1.2vw',
|
||
'& .MuiTabs-indicator': {
|
||
height: '0.35vw',
|
||
borderRadius: '999px',
|
||
background: GRADIENT,
|
||
},
|
||
'&:focus': {
|
||
background: 'transparent',
|
||
},
|
||
}}
|
||
>
|
||
{['Прокачка', 'Кейсы', 'Плащи', 'Предметы'].map((label) => (
|
||
<Tab
|
||
key={label}
|
||
label={label}
|
||
disableRipple
|
||
sx={{
|
||
minHeight: '3vw',
|
||
fontFamily: 'Benzin-Bold',
|
||
color: 'rgba(255,255,255,0.85)',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: 0.4,
|
||
borderRadius: '999px',
|
||
mx: '0.2vw',
|
||
'&.Mui-selected': {
|
||
color: '#fff',
|
||
},
|
||
'&:hover': {
|
||
color: '#fff',
|
||
background: 'rgba(255,255,255,0.06)',
|
||
},
|
||
'&:focus': {
|
||
background: 'transparent',
|
||
},
|
||
transition: 'all 0.18s ease',
|
||
}}
|
||
/>
|
||
))}
|
||
</Tabs>
|
||
<TabPanel value={tabValue} index={0}>
|
||
{/* Блок прокачки */}
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 2,
|
||
}}
|
||
>
|
||
{/* <Typography
|
||
variant="h6"
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
backgroundImage:
|
||
'linear-gradient(136deg, rgb(242,113,33), rgb(233,64,87), rgb(138,35,135))',
|
||
WebkitBackgroundClip: 'text',
|
||
WebkitTextFillColor: 'transparent',
|
||
}}
|
||
>
|
||
Прокачка
|
||
</Typography> */}
|
||
|
||
{bonusesLoading ? (
|
||
<FullScreenLoader
|
||
fullScreen={false}
|
||
message="Загрузка прокачки..."
|
||
/>
|
||
) : bonusTypes.length > 0 ? (
|
||
<Grid container spacing={2} sx={{ mb: 4 }}>
|
||
{bonusTypes.map((bt) => {
|
||
const userBonus = userBonuses.find(
|
||
(ub) => ub.bonus_type_id === bt.id,
|
||
);
|
||
const owned = !!userBonus;
|
||
|
||
const level = owned ? userBonus!.level : 0;
|
||
const effectValue = owned
|
||
? userBonus!.effect_value
|
||
: bt.base_effect_value;
|
||
const nextEffectValue =
|
||
owned && userBonus!.can_upgrade
|
||
? bt.base_effect_value +
|
||
userBonus!.level * bt.effect_increment
|
||
: undefined;
|
||
|
||
const isActive = owned ? userBonus!.is_active : false;
|
||
const isPermanent = owned
|
||
? userBonus!.is_permanent
|
||
: bt.duration === 0;
|
||
|
||
const cardId = owned ? userBonus!.id : bt.id;
|
||
const processing = processingBonusIds.includes(cardId);
|
||
|
||
return (
|
||
<Grid item xs={12} sm={6} md={4} lg={3} key={bt.id}>
|
||
<BonusShopItem
|
||
id={cardId}
|
||
name={bt.name}
|
||
description={bt.description}
|
||
imageUrl={bt.image_url}
|
||
level={level}
|
||
effectValue={effectValue}
|
||
nextEffectValue={nextEffectValue}
|
||
price={bt.price}
|
||
upgradePrice={bt.upgrade_price}
|
||
canUpgrade={userBonus?.can_upgrade ?? false}
|
||
mode={owned ? 'upgrade' : 'buy'}
|
||
isActive={isActive}
|
||
isPermanent={isPermanent}
|
||
disabled={processing}
|
||
onBuy={
|
||
!owned
|
||
? () => handlePurchaseBonus(bt.id)
|
||
: undefined
|
||
}
|
||
onUpgrade={
|
||
owned
|
||
? () => handleUpgradeBonus(userBonus!.id)
|
||
: undefined
|
||
}
|
||
onToggleActive={
|
||
owned
|
||
? () => handleToggleBonusActivation(userBonus!.id)
|
||
: undefined
|
||
}
|
||
/>
|
||
</Grid>
|
||
);
|
||
})}
|
||
</Grid>
|
||
) : (
|
||
<Typography>Прокачка временно недоступна.</Typography>
|
||
)}
|
||
</Box>
|
||
</TabPanel>
|
||
|
||
<TabPanel value={tabValue} index={1}>
|
||
{/* Блок кейсов */}
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
alignItems: 'flex-start',
|
||
gap: 2,
|
||
mb: 1,
|
||
flexDirection: 'column',
|
||
}}
|
||
>
|
||
{caseServers.length > 1 ? (
|
||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||
<Typography
|
||
variant="h6"
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
backgroundImage:
|
||
'linear-gradient(136deg, rgb(242,113,33), rgb(233,64,87), rgb(138,35,135))',
|
||
WebkitBackgroundClip: 'text',
|
||
WebkitTextFillColor: 'transparent',
|
||
}}
|
||
>
|
||
Выберите сервер:
|
||
</Typography>
|
||
|
||
<FormControl
|
||
size="small"
|
||
sx={{
|
||
// textTransform: 'uppercase',
|
||
// чтобы по ширине вел себя как бейдж
|
||
// minWidth: '18vw',
|
||
// maxWidth: '28vw',
|
||
|
||
'& .MuiInputLabel-root': { display: 'none' },
|
||
|
||
'& .MuiOutlinedInput-root': {
|
||
borderRadius: '3vw',
|
||
position: 'relative',
|
||
px: '1.2vw',
|
||
py: '0.5vw',
|
||
|
||
color: 'rgba(255,255,255,0.95)',
|
||
background:
|
||
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.20), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.16), transparent 55%), rgba(10,10,20,0.92)',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
boxShadow: '0 1.4vw 3.8vw rgba(0,0,0,0.55)',
|
||
backdropFilter: 'blur(14px)',
|
||
overflow: 'hidden',
|
||
|
||
transition: 'transform 0.18s ease, filter 0.18s ease',
|
||
'&:hover': {
|
||
transform: 'scale(1.02)',
|
||
// filter: 'brightness(1.03)',
|
||
},
|
||
|
||
'& .MuiOutlinedInput-notchedOutline': {
|
||
border: 'none',
|
||
},
|
||
|
||
// нижняя градиентная полоска как у username
|
||
'&:after': {
|
||
content: '""',
|
||
position: 'absolute',
|
||
left: '0%',
|
||
right: '0%',
|
||
bottom: 0,
|
||
height: '0.15vw',
|
||
borderRadius: '999px',
|
||
background: GRADIENT,
|
||
opacity: 0.9,
|
||
pointerEvents: 'none',
|
||
width: '100%',
|
||
},
|
||
},
|
||
|
||
'& .MuiSelect-select': {
|
||
// стиль текста как у плашки username
|
||
fontFamily: 'Benzin-Bold',
|
||
fontSize: '1.9vw',
|
||
lineHeight: 1.1,
|
||
textAlign: 'center',
|
||
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
|
||
// убираем “инпутный” паддинг
|
||
padding: 0,
|
||
minHeight: 'unset',
|
||
|
||
// чтобы длинные названия не ломали
|
||
whiteSpace: 'nowrap',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
},
|
||
}}
|
||
>
|
||
<Select
|
||
labelId="cases-server-label"
|
||
label="Сервер"
|
||
value={selectedCaseServerIp}
|
||
onChange={(e) =>
|
||
setSelectedCaseServerIp(String(e.target.value))
|
||
}
|
||
MenuProps={{
|
||
PaperProps: {
|
||
sx: {
|
||
mt: 1,
|
||
bgcolor: 'rgba(10,10,20,0.96)',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
borderRadius: '1vw',
|
||
backdropFilter: 'blur(14px)',
|
||
'& .MuiMenuItem-root': {
|
||
color: 'rgba(255,255,255,0.9)',
|
||
fontFamily: 'Benzin-Bold',
|
||
fontSize: '1.1vw',
|
||
},
|
||
'& .MuiMenuItem-root.Mui-selected': {
|
||
backgroundColor: 'rgba(242,113,33,0.18)',
|
||
},
|
||
'& .MuiMenuItem-root:hover': {
|
||
backgroundColor: 'rgba(233,64,205,0.14)',
|
||
},
|
||
},
|
||
},
|
||
}}
|
||
>
|
||
{caseServers.map((ip) => (
|
||
<MenuItem key={ip} value={ip}>
|
||
{translateServer(`Server ${ip}`)}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
</FormControl>
|
||
</Box>
|
||
) : (
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: '1vw', position: 'absolute', top: '93%', left: '35%' }}>
|
||
<Typography
|
||
variant="h6"
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
backgroundImage:
|
||
'linear-gradient(136deg, rgb(242,113,33), rgb(233,64,87), rgb(138,35,135))',
|
||
WebkitBackgroundClip: 'text',
|
||
WebkitTextFillColor: 'transparent',
|
||
}}
|
||
>
|
||
Сервер:
|
||
</Typography>
|
||
<Typography
|
||
variant="h6"
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
alignSelf: 'center',
|
||
justifySelf: 'center',
|
||
textAlign: 'center',
|
||
fontSize: '2vw',
|
||
position: 'relative',
|
||
px: '1vw',
|
||
borderRadius: '3vw',
|
||
color: 'rgba(255,255,255,0.95)',
|
||
background:
|
||
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.20), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.16), transparent 55%), rgba(10,10,20,0.92)',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
boxShadow: '0 1.4vw 3.8vw rgba(0,0,0,0.55)',
|
||
backdropFilter: 'blur(14px)',
|
||
overflow: 'hidden',
|
||
'&:after': {
|
||
content: '""',
|
||
position: 'absolute',
|
||
left: '8%',
|
||
right: '8%',
|
||
bottom: 0,
|
||
height: '0.35vw',
|
||
borderRadius: '999px',
|
||
background: GRADIENT,
|
||
opacity: 0.9,
|
||
},
|
||
}}
|
||
>
|
||
{caseServers.map((ip) => (
|
||
<MenuItem key={ip} value={ip} sx={{ fontFamily: 'Benzin-Bold' }}>
|
||
{translateServer(`Server ${ip}`)}
|
||
</MenuItem>
|
||
))}
|
||
</Typography>
|
||
</Box>
|
||
)}
|
||
|
||
{casesLoading ? (
|
||
<FullScreenLoader
|
||
fullScreen={false}
|
||
message="Загрузка кейсов..."
|
||
/>
|
||
) : cases.length > 0 ? (
|
||
<Grid container spacing={2} sx={{ mb: 4 }}>
|
||
{filteredCases.map((c) => (
|
||
<Grid item xs={12} sm={6} md={4} lg={3} key={c.id}>
|
||
<ShopItem
|
||
type="case"
|
||
id={c.id}
|
||
name={c.name}
|
||
description={c.description}
|
||
imageUrl={c.image_url}
|
||
price={c.price}
|
||
itemsCount={c.items_count}
|
||
isOpening={isOpening && selectedCase?.id === c.id}
|
||
disabled={isOpening || !selectedCaseServerIp}
|
||
onClick={() => handleOpenCase(c)}
|
||
/>
|
||
</Grid>
|
||
))}
|
||
</Grid>
|
||
) : (
|
||
<Typography>Кейсы временно недоступны.</Typography>
|
||
)}
|
||
</Box>
|
||
</TabPanel>
|
||
|
||
<TabPanel value={tabValue} index={2}>
|
||
{/* Блок плащей */}
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
<Typography
|
||
variant="h6"
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
backgroundImage:
|
||
'linear-gradient(136deg, rgb(242,113,33), rgb(233,64,87), rgb(138,35,135))',
|
||
WebkitBackgroundClip: 'text',
|
||
WebkitTextFillColor: 'transparent',
|
||
}}
|
||
>
|
||
Плащи
|
||
</Typography>
|
||
|
||
{availableCapes.length > 0 ? (
|
||
<Grid container spacing={2}>
|
||
{availableCapes.map((cape) => (
|
||
<Grid item xs={12} sm={6} md={4} lg={3} key={cape.id}>
|
||
<ShopItem
|
||
type="cape"
|
||
id={cape.id}
|
||
name={cape.name}
|
||
description={cape.description}
|
||
imageUrl={cape.image_url}
|
||
price={cape.price}
|
||
disabled={false}
|
||
playerSkinUrl={playerSkinUrl}
|
||
onClick={() => handlePurchaseCape(cape.id)}
|
||
/>
|
||
</Grid>
|
||
))}
|
||
</Grid>
|
||
) : (
|
||
<Typography>У вас уже есть все доступные плащи!</Typography>
|
||
)}
|
||
</Box>
|
||
</TabPanel>
|
||
|
||
<TabPanel value={tabValue} index={3}>
|
||
<Grid container spacing={2}>
|
||
{prankCommands.map((cmd) => (
|
||
<Grid item xs={12} sm={6} md={4} lg={3} key={cmd.id}>
|
||
<Box
|
||
sx={{
|
||
width: '20vw',
|
||
height: '100%',
|
||
borderRadius: '1.4vw',
|
||
background:
|
||
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.14), transparent 55%), rgba(10,10,20,0.88)',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
|
||
backdropFilter: 'blur(14px)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
overflow: 'hidden',
|
||
transition: 'transform 0.18s ease',
|
||
'&:hover': {
|
||
transform: 'scale(1.03)',
|
||
},
|
||
}}
|
||
>
|
||
{/* Картинка */}
|
||
<Box
|
||
sx={{
|
||
height: '9vw',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
background: 'rgba(0,0,0,0.25)',
|
||
}}
|
||
>
|
||
<Box
|
||
component="img"
|
||
src={`https://cdn.minecraft.popa-popa.ru/textures/${cmd.material?.toLowerCase()}.png`}
|
||
alt={cmd.material}
|
||
sx={{
|
||
width: '6vw',
|
||
height: '6vw',
|
||
imageRendering: 'pixelated',
|
||
}}
|
||
/>
|
||
</Box>
|
||
|
||
{/* Контент */}
|
||
<Box
|
||
sx={{
|
||
p: '1vw',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '0.6vw',
|
||
flexGrow: 1,
|
||
}}
|
||
>
|
||
<Typography
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
fontSize: '1.1vw',
|
||
background: GRADIENT,
|
||
WebkitBackgroundClip: 'text',
|
||
WebkitTextFillColor: 'transparent',
|
||
}}
|
||
>
|
||
{cmd.name}
|
||
</Typography>
|
||
|
||
<Typography
|
||
sx={{
|
||
color: 'rgba(255,255,255,0.75)',
|
||
fontSize: '0.95vw',
|
||
flexGrow: 1,
|
||
}}
|
||
>
|
||
{cmd.description}
|
||
</Typography>
|
||
|
||
<Typography
|
||
sx={{
|
||
fontWeight: 900,
|
||
fontSize: '1vw',
|
||
}}
|
||
>
|
||
Цена: {cmd.price} монет
|
||
</Typography>
|
||
|
||
<Button
|
||
disableRipple
|
||
onClick={() => {
|
||
setSelectedPrank(cmd);
|
||
setPrankTarget('');
|
||
setPrankDialogOpen(true);
|
||
}}
|
||
sx={{
|
||
mt: '0.6vw',
|
||
borderRadius: '999px',
|
||
fontFamily: 'Benzin-Bold',
|
||
color: '#fff',
|
||
background: GRADIENT,
|
||
py: '0.5vw',
|
||
'&:hover': { filter: 'brightness(1.05)' },
|
||
}}
|
||
>
|
||
Купить
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
</Grid>
|
||
))}
|
||
</Grid>
|
||
</TabPanel>
|
||
</Box>
|
||
)}
|
||
|
||
<Dialog
|
||
open={prankDialogOpen}
|
||
onClose={() => setPrankDialogOpen(false)}
|
||
fullWidth
|
||
maxWidth="xs"
|
||
PaperProps={{ sx: GLASS_DIALOG_SX }}
|
||
>
|
||
<DialogTitle sx={{ fontFamily: 'Benzin-Bold' }}>
|
||
{selectedPrank?.name}
|
||
</DialogTitle>
|
||
|
||
<DialogContent dividers sx={{ borderColor: 'rgba(255,255,255,0.10)' }}>
|
||
<Typography sx={{ opacity: 0.75, mb: 2 }}>
|
||
{selectedPrank?.description}
|
||
</Typography>
|
||
|
||
{/* сервер */}
|
||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||
<Select
|
||
value={prankServerId}
|
||
onChange={(e) => setPrankServerId(String(e.target.value))}
|
||
displayEmpty
|
||
>
|
||
<MenuItem disabled value="">
|
||
Выберите сервер
|
||
</MenuItem>
|
||
{prankServers.map((s) => (
|
||
<MenuItem key={s.id} value={s.id}>
|
||
{translateServer(s.name)} ({s.online_players}/{s.max_players})
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
</FormControl>
|
||
|
||
{/* цель */}
|
||
<TextField
|
||
fullWidth
|
||
label="Ник игрока"
|
||
value={prankTarget}
|
||
onChange={(e) => setPrankTarget(e.target.value)}
|
||
/>
|
||
</DialogContent>
|
||
|
||
<DialogActions sx={{ p: '1.2vw' }}>
|
||
<Button
|
||
onClick={() => setPrankDialogOpen(false)}
|
||
sx={{ color: 'rgba(255,255,255,0.75)', fontFamily: 'Benzin-Bold' }}
|
||
>
|
||
Отмена
|
||
</Button>
|
||
|
||
<Button
|
||
disabled={!prankTarget || !prankServerId || prankProcessing}
|
||
onClick={async () => {
|
||
if (!selectedPrank) return;
|
||
|
||
try {
|
||
setPrankProcessing(true);
|
||
await executePrank(
|
||
username,
|
||
selectedPrank.id,
|
||
prankTarget,
|
||
prankServerId,
|
||
);
|
||
|
||
playBuySound();
|
||
showNotification(
|
||
selectedPrank.globalDescription
|
||
.replace('{username}', username)
|
||
.replace('{targetPlayer}', prankTarget),
|
||
'success',
|
||
);
|
||
|
||
setPrankDialogOpen(false);
|
||
} catch (e) {
|
||
showNotification(
|
||
e instanceof Error ? e.message : 'Ошибка выполнения',
|
||
'error',
|
||
);
|
||
} finally {
|
||
setPrankProcessing(false);
|
||
}
|
||
}}
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
color: '#fff',
|
||
background: GRADIENT,
|
||
borderRadius: '999px',
|
||
px: '1.6vw',
|
||
}}
|
||
>
|
||
Подтвердить
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
|
||
{/* Компонент с анимацией рулетки */}
|
||
<CaseRoulette
|
||
open={rouletteOpen}
|
||
onClose={handleCloseRoulette}
|
||
caseName={selectedCase?.name}
|
||
items={rouletteCaseItems}
|
||
reward={rouletteReward}
|
||
/>
|
||
|
||
{/* Уведомления */}
|
||
<CustomNotification
|
||
open={notifOpen}
|
||
message={notifMsg}
|
||
severity={notifSeverity}
|
||
position={notifPos}
|
||
onClose={() => setNotifOpen(false)}
|
||
autoHideDuration={2500}
|
||
/>
|
||
</Box>
|
||
);
|
||
}
|