Files
popa-launcher/src/renderer/pages/Shop.tsx
2025-12-29 10:04:58 +05:00

1220 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}