diff --git a/src/renderer/api/commands.ts b/src/renderer/api/commands.ts new file mode 100644 index 0000000..2f74c53 --- /dev/null +++ b/src/renderer/api/commands.ts @@ -0,0 +1,59 @@ +import { API_BASE_URL } from '../api'; + +export interface PrankCommand { + id: string; + name: string; + description: string; + price: number; + command_template: string; + server_ids: string[]; // ["*"] или конкретные id + targetDescription: string; + globalDescription: string; +} + +export interface PrankServer { + id: string; + name: string; + ip: string; + online_players: number; + max_players: number; +} + +export const fetchPrankCommands = async (): Promise => { + const res = await fetch(`${API_BASE_URL}/api/pranks/commands`); + if (!res.ok) throw new Error('Failed to load prank commands'); + return res.json(); +}; + +export const fetchPrankServers = async (): Promise => { + const res = await fetch(`${API_BASE_URL}/api/pranks/servers`); + if (!res.ok) throw new Error('Failed to load prank servers'); + return res.json(); +}; + +export const executePrank = async ( + username: string, + commandId: string, + targetPlayer: string, + serverId: string, +) => { + const res = await fetch( + `${API_BASE_URL}/api/pranks/execute?username=${username}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + command_id: commandId, + target_player: targetPlayer, + server_id: serverId, + }), + }, + ); + + if (!res.ok) { + const err = await res.text(); + throw new Error(err); + } + + return res.json(); +}; diff --git a/src/renderer/pages/Shop.tsx b/src/renderer/pages/Shop.tsx index c7038a8..b03061c 100644 --- a/src/renderer/pages/Shop.tsx +++ b/src/renderer/pages/Shop.tsx @@ -1,6 +1,19 @@ import { - Box, Typography, Button, Grid, - FormControl, Select, MenuItem, InputLabel + Box, + Typography, + Button, + Grid, + FormControl, + Select, + MenuItem, + InputLabel, + Tabs, + Tab, + TextField, + Dialog, + DialogActions, + DialogContent, + DialogTitle, } from '@mui/material'; import { Cape, @@ -32,9 +45,36 @@ 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 { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications'; +import { + executePrank, + fetchPrankCommands, + fetchPrankServers, + PrankCommand, + PrankServer, +} from '../api/commands'; + +import { + isNotificationsEnabled, + getNotifPositionFromSettings, +} from '../utils/notifications'; import { translateServer } from '../utils/serverTranslator'; +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + return ( + + ); +} + function getRarityByWeight( weight?: number, ): 'common' | 'rare' | 'epic' | 'legendary' { @@ -64,6 +104,15 @@ function getRarityColor(weight?: number): string { 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([]); const [userCapes, setUserCapes] = useState([]); @@ -112,6 +161,33 @@ export default function Shop() { const [rouletteCaseItems, setRouletteCaseItems] = useState([]); const [rouletteReward, setRouletteReward] = useState(null); + // TABS + const [tabValue, setTabValue] = useState(0); + + const handleTabChange = (_: React.SyntheticEvent, newValue: number) => { + setTabValue(newValue); + }; + + //команды + + const [prankCommands, setPrankCommands] = useState([]); + const [prankServers, setPrankServers] = useState([]); + + const [pranksLoading, setPranksLoading] = useState(false); + + const [selectedPrankServer, setSelectedPrankServer] = useState(''); + const [targetPlayer, setTargetPlayer] = useState(''); + const [processingCommandId, setProcessingCommandId] = useState( + null, + ); + + const [prankDialogOpen, setPrankDialogOpen] = useState(false); + const [selectedPrank, setSelectedPrank] = useState(null); + + const [prankTarget, setPrankTarget] = useState(''); + const [prankServerId, setPrankServerId] = useState(''); + const [prankProcessing, setPrankProcessing] = useState(false); + // Уведомления const [notification, setNotification] = useState<{ open: boolean; @@ -148,7 +224,7 @@ export default function Shop() { console.error('Ошибка при получении прокачек:', error); if (!isNotificationsEnabled()) return; - showNotification('Ошибка при загрузке прокачки!', 'error') + showNotification('Ошибка при загрузке прокачки!', 'error'); } finally { setBonusesLoading(false); } @@ -175,6 +251,31 @@ export default function Shop() { } }; + // Загрузка команд + 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 { @@ -194,12 +295,12 @@ export default function Shop() { playBuySound(); if (!isNotificationsEnabled()) return; - showNotification('Плащ успешно куплен!', 'success') + showNotification('Плащ успешно куплен!', 'success'); } catch (error) { console.error('Ошибка при покупке плаща:', error); if (!isNotificationsEnabled()) return; - showNotification('Ошибка при покупке плаща!', 'error') + showNotification('Ошибка при покупке плаща!', 'error'); } }; @@ -279,9 +380,11 @@ export default function Shop() { const handlePurchaseBonus = async (bonusTypeId: string) => { if (!username) { - if (!isNotificationsEnabled()) return; - showNotification('Не найдено имя игрока. Авторизируйтесь в лаунчере!', 'error') + showNotification( + 'Не найдено имя игрока. Авторизируйтесь в лаунчере!', + 'error', + ); return; } @@ -294,12 +397,12 @@ export default function Shop() { await loadBonuses(username); if (!isNotificationsEnabled()) return; - showNotification('Прокачка успешно куплена!', 'success') + showNotification('Прокачка успешно куплена!', 'success'); } catch (error) { console.error('Ошибка при покупке прокачки:', error); if (!isNotificationsEnabled()) return; - showNotification('Ошибка при прокачке!', 'error') + showNotification('Ошибка при прокачке!', 'error'); } }); }; @@ -314,12 +417,12 @@ export default function Shop() { await loadBonuses(username); if (!isNotificationsEnabled()) return; - showNotification('Бонус улучшен!', 'success') + showNotification('Бонус улучшен!', 'success'); } catch (error) { console.error('Ошибка при улучшении бонуса:', error); if (!isNotificationsEnabled()) return; - showNotification('Ошибка при улучшении бонуса!', 'error') + showNotification('Ошибка при улучшении бонуса!', 'error'); } }); }; @@ -334,39 +437,35 @@ export default function Shop() { } catch (error) { console.error('Ошибка при переключении бонуса:', error); if (!isNotificationsEnabled()) return; - showNotification('Ошибка при переключении бонуса!', 'error') + showNotification('Ошибка при переключении бонуса!', 'error'); } }); }; const caseServers = Array.from( - new Set( - (cases || []) - .flatMap((c) => c.server_ips || []) - .filter(Boolean), - ), -); + 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]; + useEffect(() => { + if (caseServers.length > 0) { + // если игрок онлайн — по умолчанию его сервер, если он есть в кейсах + const preferred = + playerServer?.ip && caseServers.includes(playerServer.ip) + ? playerServer.ip + : caseServers[0]; - setSelectedCaseServerIp(preferred); - } -}, [caseServers.length, playerServer?.ip]); + setSelectedCaseServerIp(preferred); + } + }, [caseServers.length, playerServer?.ip]); -const filteredCases = (cases || []).filter((c) => { - const allowed = c.server_ips || []; - // если список пуст — значит кейс доступен везде - if (allowed.length === 0) return true; + const filteredCases = (cases || []).filter((c) => { + const allowed = c.server_ips || []; + // если список пуст — значит кейс доступен везде + if (allowed.length === 0) return true; - // иначе — показываем только те, где выбранный сервер разрешён - return !!selectedCaseServerIp && allowed.includes(selectedCaseServerIp); -}); + // иначе — показываем только те, где выбранный сервер разрешён + return !!selectedCaseServerIp && allowed.includes(selectedCaseServerIp); + }); // Фильтруем плащи, которые уже куплены пользователем const availableCapes = storeCapes.filter( @@ -375,55 +474,70 @@ const filteredCases = (cases || []).filter((c) => { ); const handleOpenCase = async (caseData: Case) => { - if (!username) { - if (!isNotificationsEnabled()) return; - showNotification('Не найдено имя игрока. Авторизуйтесь в лаунчере!', 'error') - return; - } + if (!username) { + if (!isNotificationsEnabled()) return; + showNotification( + 'Не найдено имя игрока. Авторизуйтесь в лаунчере!', + 'error', + ); + return; + } - if (!selectedCaseServerIp) { - if (!isNotificationsEnabled()) return; - showNotification('Выберите сервер для открытия кейса!', 'warning') - 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; - } + 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; + if (isOpening) return; - try { - setIsOpening(true); + try { + setIsOpening(true); - const fullCase = await fetchCase(caseData.id); - const caseItems: CaseItem[] = fullCase.items || []; - setSelectedCase(fullCase); + const fullCase = await fetchCase(caseData.id); + const caseItems: CaseItem[] = fullCase.items || []; + setSelectedCase(fullCase); - // ✅ открываем на выбранном сервере (даже если игрок не на сервере) - const result = await openCase(fullCase.id, username, selectedCaseServerIp); + // ✅ открываем на выбранном сервере (даже если игрок не на сервере) + const result = await openCase( + fullCase.id, + username, + selectedCaseServerIp, + ); - setRouletteCaseItems(caseItems); - setRouletteReward(result.reward); - setRouletteOpen(true); - playBuySound(); + setRouletteCaseItems(caseItems); + setRouletteReward(result.reward); + setRouletteOpen(true); + playBuySound(); - if (!isNotificationsEnabled()) return; - showNotification('Кейс открыт!', 'success') - } catch (error) { - console.error('Ошибка при открытии кейса:', error); + if (!isNotificationsEnabled()) return; + showNotification('Кейс открыт!', 'success'); + } catch (error) { + console.error('Ошибка при открытии кейса:', error); - if (!isNotificationsEnabled()) return; - showNotification((String(error instanceof Error ? error.message : 'Ошибка при открытии кейса!')), 'error') - } finally { - setIsOpening(false); - } -}; + if (!isNotificationsEnabled()) return; + showNotification( + String( + error instanceof Error ? error.message : 'Ошибка при открытии кейса!', + ), + 'error', + ); + } finally { + setIsOpening(false); + } + }; const handleCloseNotification = () => { setNotification((prev) => ({ ...prev, open: false })); @@ -470,110 +584,55 @@ const filteredCases = (cases || []).filter((c) => { paddingRight: '1.5vw', }} > - {/* Блок прокачки */} - - ( + + ))} + + + {/* Блок прокачки */} + - Прокачка - - - {bonusesLoading ? ( - - ) : bonusTypes.length > 0 ? ( - - {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 ( - - handlePurchaseBonus(bt.id) : undefined - } - onUpgrade={ - owned - ? () => handleUpgradeBonus(userBonus!.id) - : undefined - } - onToggleActive={ - owned - ? () => handleToggleBonusActivation(userBonus!.id) - : undefined - } - /> - - ); - })} - - ) : ( - Прокачка временно недоступна. - )} - - - {/* Блок кейсов */} - - { WebkitTextFillColor: 'transparent', }} > - Кейсы + Прокачка - {caseServers.length > 0 && ( - + ) : bonusTypes.length > 0 ? ( + + {bonusTypes.map((bt) => { + const userBonus = userBonuses.find( + (ub) => ub.bonus_type_id === bt.id, + ); + const owned = !!userBonus; - '& .MuiInputLabel-root': { display: 'none' }, + 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; - '& .MuiOutlinedInput-root': { - borderRadius: '3vw', - position: 'relative', - px: '1.2vw', - py: '0.5vw', + const isActive = owned ? userBonus!.is_active : false; + const isPermanent = owned + ? userBonus!.is_permanent + : bt.duration === 0; - 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', + const cardId = owned ? userBonus!.id : bt.id; + const processing = processingBonusIds.includes(cardId); - 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', - }, - }} - > - - - - // - // - // Сервер - // - // - // - )} + return ( + + handlePurchaseBonus(bt.id) + : undefined + } + onUpgrade={ + owned + ? () => handleUpgradeBonus(userBonus!.id) + : undefined + } + onToggleActive={ + owned + ? () => handleToggleBonusActivation(userBonus!.id) + : undefined + } + /> + + ); + })} + + ) : ( + Прокачка временно недоступна. + )} + - {casesLoading ? ( - - ) : cases.length > 0 ? ( - - {filteredCases.map((c) => ( - - handleOpenCase(c)} - /> - - ))} - - ) : ( - Кейсы временно недоступны. - )} - - - {/* Блок плащей (как был) */} - - {/* Блок плащей */} - - + {/* Блок кейсов */} + - Плащи - + + + Кейсы + - {availableCapes.length > 0 ? ( - - {availableCapes.map((cape) => ( - - handlePurchaseCape(cape.id)} - /> - - ))} - - ) : ( - У вас уже есть все доступные плащи! - )} - + {caseServers.length > 0 && ( + + + + + // + // + // Сервер + // + // + // + )} + + + {casesLoading ? ( + + ) : cases.length > 0 ? ( + + {filteredCases.map((c) => ( + + handleOpenCase(c)} + /> + + ))} + + ) : ( + Кейсы временно недоступны. + )} + + + + + {/* Блок плащей */} + + + Плащи + + + {availableCapes.length > 0 ? ( + + {availableCapes.map((cape) => ( + + handlePurchaseCape(cape.id)} + /> + + ))} + + ) : ( + У вас уже есть все доступные плащи! + )} + + + + + + {prankCommands.map((cmd) => ( + + + + {cmd.name} + + + + {cmd.description} + + + + Цена: {cmd.price} монет + + + + + + ))} + + )} + setPrankDialogOpen(false)} + fullWidth + maxWidth="xs" + PaperProps={{ sx: GLASS_DIALOG_SX }} + > + + {selectedPrank?.name} + + + + + {selectedPrank?.description} + + + {/* сервер */} + + + + + {/* цель */} + setPrankTarget(e.target.value)} + /> + + + + + + + + + {/* Компонент с анимацией рулетки */}