add commands tab in shop

This commit is contained in:
2025-12-28 14:12:07 +05:00
parent 4b8e535c58
commit 8003e3567a
2 changed files with 787 additions and 403 deletions

View File

@ -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<PrankCommand[]> => {
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<PrankServer[]> => {
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();
};

View File

@ -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 (
<div role="tabpanel" hidden={value !== index} {...other}>
{value === index && <Box sx={{ pt: '1.4vw' }}>{children}</Box>}
</div>
);
}
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<StoreCape[]>([]);
const [userCapes, setUserCapes] = useState<Cape[]>([]);
@ -112,6 +161,33 @@ export default function Shop() {
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;
@ -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,17 +437,13 @@ 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(() => {
@ -377,22 +476,28 @@ const filteredCases = (cases || []).filter((c) => {
const handleOpenCase = async (caseData: Case) => {
if (!username) {
if (!isNotificationsEnabled()) return;
showNotification('Не найдено имя игрока. Авторизуйтесь в лаунчере!', 'error')
showNotification(
'Не найдено имя игрока. Авторизуйтесь в лаунчере!',
'error',
);
return;
}
if (!selectedCaseServerIp) {
if (!isNotificationsEnabled()) return;
showNotification('Выберите сервер для открытия кейса!', 'warning')
showNotification('Выберите сервер для открытия кейса!', 'warning');
return;
}
const allowedIps = caseData.server_ips || [];
if (allowedIps.length > 0 && !allowedIps.includes(selectedCaseServerIp)) {
if (!isNotificationsEnabled()) return;
showNotification(`Этот кейс доступен на: ${allowedIps
showNotification(
`Этот кейс доступен на: ${allowedIps
.map((ip) => translateServer(`Server ${ip}`))
.join(', ')}`, 'warning')
.join(', ')}`,
'warning',
);
return;
}
@ -406,7 +511,11 @@ const filteredCases = (cases || []).filter((c) => {
setSelectedCase(fullCase);
// ✅ открываем на выбранном сервере (даже если игрок не на сервере)
const result = await openCase(fullCase.id, username, selectedCaseServerIp);
const result = await openCase(
fullCase.id,
username,
selectedCaseServerIp,
);
setRouletteCaseItems(caseItems);
setRouletteReward(result.reward);
@ -414,12 +523,17 @@ const filteredCases = (cases || []).filter((c) => {
playBuySound();
if (!isNotificationsEnabled()) return;
showNotification('Кейс открыт!', 'success')
showNotification('Кейс открыт!', 'success');
} catch (error) {
console.error('Ошибка при открытии кейса:', error);
if (!isNotificationsEnabled()) return;
showNotification((String(error instanceof Error ? error.message : 'Ошибка при открытии кейса!')), 'error')
showNotification(
String(
error instanceof Error ? error.message : 'Ошибка при открытии кейса!',
),
'error',
);
} finally {
setIsOpening(false);
}
@ -470,13 +584,53 @@ const filteredCases = (cases || []).filter((c) => {
paddingRight: '1.5vw',
}}
>
<Tabs
value={tabValue}
onChange={handleTabChange}
disableRipple
sx={{
minHeight: '3vw',
mb: '1.2vw',
'& .MuiTabs-indicator': {
height: '0.35vw',
borderRadius: '999px',
background: GRADIENT,
},
}}
>
{['Прокачка', 'Кейсы', 'Плащи', 'Предметы/пакости'].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)',
},
transition: 'all 0.18s ease',
}}
/>
))}
</Tabs>
<TabPanel value={tabValue} index={0}>
{/* Блок прокачки */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
mt: '2vh'
mt: '2vh',
}}
>
<Typography
@ -541,7 +695,9 @@ const filteredCases = (cases || []).filter((c) => {
isPermanent={isPermanent}
disabled={processing}
onBuy={
!owned ? () => handlePurchaseBonus(bt.id) : undefined
!owned
? () => handlePurchaseBonus(bt.id)
: undefined
}
onUpgrade={
owned
@ -562,7 +718,9 @@ const filteredCases = (cases || []).filter((c) => {
<Typography>Прокачка временно недоступна.</Typography>
)}
</Box>
</TabPanel>
<TabPanel value={tabValue} index={1}>
{/* Блок кейсов */}
<Box
sx={{
@ -664,7 +822,9 @@ const filteredCases = (cases || []).filter((c) => {
labelId="cases-server-label"
label="Сервер"
value={selectedCaseServerIp}
onChange={(e) => setSelectedCaseServerIp(String(e.target.value))}
onChange={(e) =>
setSelectedCaseServerIp(String(e.target.value))
}
MenuProps={{
PaperProps: {
sx: {
@ -761,7 +921,10 @@ const filteredCases = (cases || []).filter((c) => {
</Box>
{casesLoading ? (
<FullScreenLoader fullScreen={false} message="Загрузка кейсов..." />
<FullScreenLoader
fullScreen={false}
message="Загрузка кейсов..."
/>
) : cases.length > 0 ? (
<Grid container spacing={2} sx={{ mb: 4 }}>
{filteredCases.map((c) => (
@ -785,9 +948,9 @@ const filteredCases = (cases || []).filter((c) => {
<Typography>Кейсы временно недоступны.</Typography>
)}
</Box>
</TabPanel>
{/* Блок плащей (как был) */}
<TabPanel value={tabValue} index={2}>
{/* Блок плащей */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography
@ -825,9 +988,171 @@ const filteredCases = (cases || []).filter((c) => {
<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={{
p: '1.4vw',
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',
gap: '0.8vw',
}}
>
<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)', fontWeight: 700 }}
>
{cmd.description}
</Typography>
<Typography sx={{ fontWeight: 900 }}>
Цена: {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.6vw',
'&:hover': { filter: 'brightness(1.05)' },
}}
>
Выполнить
</Button>
</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}