add inventory and change cases

This commit is contained in:
aurinex
2025-12-16 15:30:40 +05:00
parent 6db213d602
commit c15c36891e
10 changed files with 640 additions and 96 deletions

View File

@ -247,7 +247,7 @@ const createWindow = async () => {
width: 1024,
height: 850,
autoHideMenuBar: true,
resizable: true,
resizable: false,
frame: false,
icon: getAssetPath('popa-popa.png'),
webPreferences: {

View File

@ -34,13 +34,13 @@ body.no-blur .glass-ui {
/* SETTINGS NO-BLUR */
/* SETTINGS REDUCE-MOTION */
@media (prefers-reduced-motion: reduce) {
/* @media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
} */
body.reduce-motion *,
body.reduce-motion *::before,

View File

@ -25,6 +25,7 @@ import { useLocation } from 'react-router-dom';
import DailyReward from './pages/DailyReward';
import DailyQuests from './pages/DailyQuests';
import Settings from './pages/Settings';
import Inventory from './pages/Inventory';
import { TrayBridge } from './utils/TrayBridge';
const AuthCheck = ({ children }: { children: ReactNode }) => {
@ -287,6 +288,14 @@ const AppLayout = () => {
</AuthCheck>
}
/>
<Route
path="/inventory"
element={
<AuthCheck>
<Inventory />
</AuthCheck>
}
/>
<Route
path="/daily"
element={

View File

@ -426,6 +426,8 @@ export async function toggleBonusActivation(
export interface CaseItemMeta {
display_name?: string | null;
lore?: string[] | null;
enchants?: Record<string, number> | null;
durability?: number | null;
}
export interface CaseItem {
@ -443,7 +445,7 @@ export interface Case {
description?: string;
price: number;
image_url?: string;
server_ids?: string[];
server_ips?: string[];
items_count?: number;
items?: CaseItem[];
}
@ -457,52 +459,34 @@ export interface OpenCaseResponse {
}
export async function fetchCases(): Promise<Case[]> {
const response = await fetch(`${API_BASE_URL}/cases`);
if (!response.ok) {
throw new Error('Не удалось получить список кейсов');
}
const response = await fetch(`${API_BASE_URL}/cases/`);
if (!response.ok) throw new Error('Не удалось получить список кейсов');
return await response.json();
}
// Если у тебя есть отдельный эндпоинт деталей кейса, можно использовать это:
export async function fetchCase(case_id: string): Promise<Case> {
const response = await fetch(`${API_BASE_URL}/cases/${case_id}`);
if (!response.ok) {
throw new Error('Не удалось получить информацию о кейсе');
}
if (!response.ok) throw new Error('Не удалось получить информацию о кейсе');
return await response.json();
}
export async function openCase(
case_id: string,
username: string,
server_id: string,
server_ip: string,
): Promise<OpenCaseResponse> {
// Формируем URL с query-параметрами, как любит текущий бэкенд
const url = new URL(`${API_BASE_URL}/cases/${case_id}/open`);
url.searchParams.append('username', username);
url.searchParams.append('server_id', server_id);
url.searchParams.set('username', username);
url.searchParams.set('server_ip', server_ip);
const response = await fetch(url.toString(), {
method: 'POST',
});
const response = await fetch(url.toString(), { method: 'POST' });
if (!response.ok) {
let msg = 'Не удалось открыть кейс';
try {
const errorData = await response.json();
if (errorData.message) {
msg = errorData.message;
} else if (Array.isArray(errorData.detail)) {
msg = errorData.detail.map((d: any) => d.msg).join(', ');
} else if (typeof errorData.detail === 'string') {
msg = errorData.detail;
}
} catch {
// если бэкенд вернул не-JSON, оставляем дефолтное сообщение
}
msg = errorData.message || errorData.detail || msg;
} catch {}
throw new Error(msg);
}
@ -511,6 +495,91 @@ export async function openCase(
// ===== КЕЙСЫ ===== \\
// ===== Инвентарь =====
export interface InventoryRawItem {
_id: string;
id: string; // item_id для withdraw
username: string;
server_ip: string;
item_data: {
material: string;
amount: number;
meta?: {
display_name?: string | null;
lore?: string[] | null;
enchants?: Record<string, number> | null;
durability?: number | null;
};
};
source?: {
type: string;
case_id?: string;
case_name?: string;
};
status: 'stored' | 'delivered' | string;
created_at: string;
delivered_at?: string | null;
withdraw_operation_id?: string | null;
}
export interface InventoryItemsResponse {
items: InventoryRawItem[];
page: number;
limit: number;
total: number;
}
export async function fetchInventoryItems(
username: string,
server_ip: string,
page = 1,
limit = 28,
): Promise<InventoryItemsResponse> {
const url = new URL(`${API_BASE_URL}/inventory/items`);
url.searchParams.set('username', username);
url.searchParams.set('server_ip', server_ip);
url.searchParams.set('page', String(page));
url.searchParams.set('limit', String(limit));
const response = await fetch(url.toString());
if (!response.ok) {
let msg = 'Не удалось получить инвентарь';
try {
const err = await response.json();
msg = err.message || err.detail || msg;
} catch {}
throw new Error(msg);
}
return await response.json();
}
export async function withdrawInventoryItem(payload: {
username: string;
item_id: string;
server_ip: string;
}): Promise<{ status: string; message?: string }> {
const response = await fetch(`${API_BASE_URL}/inventory/withdraw`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
let msg = 'Не удалось выдать предмет';
try {
const err = await response.json();
msg = err.message || err.detail || msg;
} catch {}
throw new Error(msg);
}
return await response.json();
}
// ===== ИНВЕНТАРЬ ===== \\
// ===== Ежедневная награда =====
export interface DailyStatusResponse {
ok: boolean;

View File

@ -33,6 +33,7 @@ export default function PageHeader() {
path === '/registration' ||
path === '/marketplace' ||
path === '/profile' ||
path === '/inventory' ||
path.startsWith('/launch')
) {
return { title: '', subtitle: '', hidden: true };

View File

@ -21,6 +21,8 @@ import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
import PersonIcon from '@mui/icons-material/Person';
import SettingsIcon from '@mui/icons-material/Settings';
import { useTheme } from '@mui/material/styles';
import CategoryIcon from '@mui/icons-material/Category';
declare global {
interface Window {
electron: {
@ -600,6 +602,19 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
<EmojiEventsIcon sx={{ fontSize: '2vw' }} /> Ежедневная награда
</MenuItem>
<MenuItem
onClick={() => {
handleAvatarMenuClose();
navigate('/inventory');
}}
sx={[
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
theme.launcher.topbar.menuItem,
]}
>
<CategoryIcon sx={{ fontSize: '2vw' }} /> Инвентарь
</MenuItem>
<MenuItem
onClick={() => {
handleAvatarMenuClose();

View File

@ -0,0 +1,359 @@
import { useEffect, useState } from 'react';
import {
Box,
Typography,
Grid,
Button,
FormControl,
Select,
MenuItem,
InputLabel,
Tooltip,
Paper,
} from '@mui/material';
import { FullScreenLoader } from '../components/FullScreenLoader';
import { translateServer } from '../utils/serverTranslator';
import {
fetchInventoryItems,
withdrawInventoryItem,
type InventoryRawItem,
type InventoryItemsResponse,
} from '../api';
const KNOWN_SERVER_IPS = [
'minecraft.hub.popa-popa.ru',
'minecraft.survival.popa-popa.ru',
'minecraft.minigames.popa-popa.ru',
];
function stripMinecraftColors(text?: string | null): string {
if (!text) return '';
return text.replace(/§[0-9A-FK-ORa-fk-or]/g, '');
}
const Glass = ({ children }: { children: React.ReactNode }) => (
<Paper
elevation={0}
sx={{
borderRadius: '1.2vw',
overflow: 'hidden',
background:
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.14), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.86)',
border: '1px solid rgba(255,255,255,0.08)',
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
color: 'white',
}}
>
<Box sx={{ p: '1.8vw' }}>{children}</Box>
</Paper>
);
export default function Inventory() {
const [username, setUsername] = useState('');
const [loading, setLoading] = useState(true);
const [availableServers, setAvailableServers] = useState<string[]>([]);
const [selectedServerIp, setSelectedServerIp] = useState<string>('');
const [items, setItems] = useState<InventoryRawItem[]>([]);
const [page, setPage] = useState(1);
const limit = 28;
const [total, setTotal] = useState(0);
const [pages, setPages] = useState(1);
const [withdrawingIds, setWithdrawingIds] = useState<string[]>([]);
const isServerSelectVisible = availableServers.length > 1;
useEffect(() => {
const savedConfig = localStorage.getItem('launcher_config');
if (!savedConfig) {
setLoading(false);
return;
}
const config = JSON.parse(savedConfig);
if (config?.username) setUsername(config.username);
setLoading(false);
}, []);
const detectServersWithItems = async (u: string) => {
const checks = await Promise.allSettled(
KNOWN_SERVER_IPS.map(async (ip) => {
const res = await fetchInventoryItems(u, ip, 1, 1);
return { ip, has: (res.items || []).length > 0 || (res.total ?? 0) > 0 };
}),
);
const servers = checks
.filter((r): r is PromiseFulfilledResult<{ ip: string; has: boolean }> => r.status === 'fulfilled')
.filter((r) => r.value.has)
.map((r) => r.value.ip);
return servers;
};
const loadInventory = async (u: string, ip: string, p: number) => {
const res: InventoryItemsResponse = await fetchInventoryItems(u, ip, p, limit);
setItems(res.items || []);
setTotal(res.total ?? 0);
setPages(Math.max(1, Math.ceil((res.total ?? 0) / (res.limit ?? limit))));
};
useEffect(() => {
if (!username) return;
let cancelled = false;
(async () => {
try {
setLoading(true);
const servers = await detectServersWithItems(username);
if (cancelled) return;
setAvailableServers(servers);
const defaultIp = servers[0] || '';
setSelectedServerIp(defaultIp);
setPage(1);
if (defaultIp) {
await loadInventory(username, defaultIp, 1);
} else {
setItems([]);
setTotal(0);
setPages(1);
}
} catch (e) {
console.error(e);
setAvailableServers([]);
setSelectedServerIp('');
setItems([]);
setTotal(0);
setPages(1);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [username]);
useEffect(() => {
if (!username || !selectedServerIp) return;
let cancelled = false;
(async () => {
try {
setLoading(true);
setPage(1);
await loadInventory(username, selectedServerIp, 1);
} catch (e) {
console.error(e);
setItems([]);
setTotal(0);
setPages(1);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedServerIp]);
const handleWithdraw = async (item: InventoryRawItem) => {
if (!username || !selectedServerIp) return;
if (selectedServerIp !== item.server_ip) {
alert("Ошибка! Вы не на том сервере для выдачи этого предмета.");
return;
}
await withWithdrawing(item.id, async () => {
try {
await withdrawInventoryItem({
username,
item_id: item.id,
server_ip: selectedServerIp,
});
setItems((prevItems) => prevItems.filter((prevItem) => prevItem.id !== item.id));
} catch (e) {
console.error("Ошибка при выводе предмета:", e);
}
});
};
const withWithdrawing = async (id: string, fn: () => Promise<void>) => {
setWithdrawingIds((prev) => [...prev, id]);
try {
await fn();
} finally {
setWithdrawingIds((prev) => prev.filter((x) => x !== id));
}
};
const headerServerName = selectedServerIp
? translateServer(`Server ${selectedServerIp}`)
: '';
if (!username) {
return (
<Box sx={{ p: '2vw' }}>
<Typography sx={{ color: 'rgba(255,255,255,0.75)' }}>
Не найдено имя игрока. Авторизуйтесь в лаунчере.
</Typography>
</Box>
);
}
const canPrev = page > 1;
const canNext = page < pages;
const handlePrev = async () => {
if (!canPrev || !username || !selectedServerIp) return;
const nextPage = page - 1;
setPage(nextPage);
setLoading(true);
try {
await loadInventory(username, selectedServerIp, nextPage);
} finally {
setLoading(false);
}
};
const handleNext = async () => {
if (!canNext || !username || !selectedServerIp) return;
const nextPage = page + 1;
setPage(nextPage);
setLoading(true);
try {
await loadInventory(username, selectedServerIp, nextPage);
} finally {
setLoading(false);
}
};
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
overflow: 'auto',
px: '2.5vw',
py: '2vw',
gap: 2,
mt: '12vh',
}}
>
{loading && <FullScreenLoader fullScreen={false} message="Загрузка инвентаря..." />}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap', justifyContent: 'space-evenly', flexDirection: 'row-reverse' }}>
{/* Пагинация */}
{!!selectedServerIp && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
variant="outlined"
disabled={!canPrev || loading}
onClick={handlePrev}
sx={{
borderRadius: '2.5vw',
fontFamily: 'Benzin-Bold',
color: 'white',
border: '1px solid rgba(255,255,255,0.15)',
}}
>
Назад
</Button>
<Typography sx={{ color: 'rgba(255,255,255,0.7)' }}>
Страница {page} / {pages} Всего: {total}
</Typography>
<Button
variant="outlined"
disabled={!canNext || loading}
onClick={handleNext}
sx={{
borderRadius: '2.5vw',
fontFamily: 'Benzin-Bold',
color: 'white',
border: '1px solid rgba(255,255,255,0.15)',
}}
>
Вперёд
</Button>
</Box>
)}
<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',
}}
>
Инвентарь {headerServerName ? `${headerServerName}` : ''}
</Typography>
<Grid container spacing={2}>
{Array.from({ length: 28 }).map((_, index) => {
const item = items[index];
return (
<Grid item xs={3} key={index}>
<Glass>
<Box
sx={{
width: '8vw',
height: '8vw',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
borderRadius: '5px',
boxShadow: item ? '0 10px 40px rgba(0,0,0,0.3)' : 'none',
'&:hover': {
transform: 'scale(1.1)',
cursor: item ? 'pointer' : 'default',
},
transition: 'all 0.25s ease',
}}
onClick={() => item && handleWithdraw(item)}
>
{item ? (
<Tooltip
title={`${item.item_data?.meta?.display_name}\n${item.item_data?.material}`}
placement="top"
>
<Box
component="img"
src={`https://cdn.minecraft.popa-popa.ru/textures/${item.item_data?.material.toLowerCase()}.png`}
sx={{
width: '60%',
height: '60%',
objectFit: 'contain',
imageRendering: 'pixelated',
}}
/>
</Tooltip>
) : (
<Typography sx={{ color: 'rgba(255,255,255,0.5)' }}>Пусто</Typography>
)}
</Box>
</Glass>
</Grid>
);
})}
</Grid>
</Box>
</Box>
);
}

View File

@ -349,8 +349,8 @@ export default function Profile() {
}}
>
<SkinViewer
width={400}
height={465}
width={340}
height={405}
skinUrl={skin}
capeUrl={cape}
walkingSpeed={walkingSpeed}

View File

@ -357,6 +357,7 @@ const Settings = () => {
pb: '2vw',
width: '95%',
boxSizing: 'border-box',
overflowY: 'auto',
}}
>
<CustomNotification

View File

@ -1,4 +1,7 @@
import { Box, Typography, Button, Grid, Snackbar, Alert } from '@mui/material';
import {
Box, Typography, Button, Grid,
FormControl, Select, MenuItem, InputLabel
} from '@mui/material';
import {
Cape,
fetchCapes,
@ -30,6 +33,7 @@ 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 { translateServer } from '../utils/serverTranslator';
function getRarityByWeight(
weight?: number,
@ -66,6 +70,8 @@ export default function Shop() {
const [playerSkinUrl, setPlayerSkinUrl] = useState<string>('');
const [selectedCaseServerIp, setSelectedCaseServerIp] = useState<string>('');
// Уведомления
const [notifOpen, setNotifOpen] = useState(false);
@ -345,6 +351,28 @@ export default function Shop() {
});
};
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;
// Фильтруем плащи, которые уже куплены пользователем
const availableCapes = storeCapes.filter(
(storeCape) =>
@ -352,62 +380,72 @@ export default function Shop() {
);
const handleOpenCase = async (caseData: Case) => {
if (!username) {
if (!isNotificationsEnabled()) return;
setNotifMsg('Не найдено имя игрока. Авторизуйтесь в лаунчере!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
}
if (!username) {
if (!isNotificationsEnabled()) return;
setNotifMsg('Не найдено имя игрока. Авторизуйтесь в лаунчере!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
return;
}
if (!isOnline || !playerServer) {
if (!isNotificationsEnabled()) return;
setNotifMsg('Для открытия кейсов необходимо находиться на сервере в игре!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
return;
}
if (!selectedCaseServerIp) {
if (!isNotificationsEnabled()) return;
setNotifMsg('Выберите сервер для открытия кейса!');
setNotifSeverity('warning');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
return;
}
if (isOpening) return;
const allowedIps = caseData.server_ips || [];
if (allowedIps.length > 0 && !allowedIps.includes(selectedCaseServerIp)) {
if (!isNotificationsEnabled()) return;
setNotifMsg(
`Этот кейс доступен на: ${allowedIps
.map((ip) => translateServer(`Server ${ip}`))
.join(', ')}`,
);
setNotifSeverity('warning');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
return;
}
try {
setIsOpening(true);
if (isOpening) return;
// 1. получаем полный кейс
const fullCase = await fetchCase(caseData.id);
const caseItems: CaseItem[] = fullCase.items || [];
setSelectedCase(fullCase);
try {
setIsOpening(true);
// 2. открываем кейс на бэке
const result = await openCase(fullCase.id, username, playerServer.id);
const fullCase = await fetchCase(caseData.id);
const caseItems: CaseItem[] = fullCase.items || [];
setSelectedCase(fullCase);
// 3. сохраняем данные для рулетки
setRouletteCaseItems(caseItems);
setRouletteReward(result.reward);
setRouletteOpen(true);
playBuySound();
// ✅ открываем на выбранном сервере (даже если игрок не на сервере)
const result = await openCase(fullCase.id, username, selectedCaseServerIp);
setIsOpening(false);
setRouletteCaseItems(caseItems);
setRouletteReward(result.reward);
setRouletteOpen(true);
playBuySound();
// 4. уведомление
if (!isNotificationsEnabled()) return;
setNotifMsg('Кейс открыт!');
setNotifSeverity('success');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
} catch (error) {
console.error('Ошибка при открытии кейса:', error);
if (!isNotificationsEnabled()) return;
setNotifMsg('Кейс открыт!');
setNotifSeverity('success');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
} catch (error) {
console.error('Ошибка при открытии кейса:', error);
setIsOpening(false);
if (!isNotificationsEnabled()) return;
setNotifMsg('Ошибка при открытии кейса!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
}
};
if (!isNotificationsEnabled()) return;
setNotifMsg(String(error instanceof Error ? error.message : 'Ошибка при открытии кейса!'));
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
} finally {
setIsOpening(false);
}
};
const handleCloseNotification = () => {
setNotification((prev) => ({ ...prev, open: false }));
@ -570,8 +608,6 @@ export default function Shop() {
>
Кейсы
</Typography>
{!isOnline && (
<Button
disableRipple
disableFocusRipple
@ -599,22 +635,76 @@ export default function Shop() {
>
Обновить
</Button>
)}
{caseServers.length > 0 && (
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel id="cases-server-label" sx={{ fontFamily: 'Benzin-Bold', color: 'rgba(255,255,255,0.75)' }}>
Сервер
</InputLabel>
<Select
labelId="cases-server-label"
label="Сервер"
value={selectedCaseServerIp}
onChange={(e) => setSelectedCaseServerIp(String(e.target.value))}
MenuProps={{
PaperProps: {
sx: {
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',
},
'& .MuiMenuItem-root.Mui-selected': {
backgroundColor: 'rgba(242,113,33,0.16)',
},
'& .MuiMenuItem-root:hover': {
backgroundColor: 'rgba(233,64,205,0.14)',
},
},
},
}}
sx={{
borderRadius: '999px',
bgcolor: 'rgba(255,255,255,0.04)',
color: 'rgba(255,255,255,0.92)',
fontFamily: 'Benzin-Bold',
'& .MuiSelect-select': {
py: '0.9vw',
px: '1.2vw',
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(255,255,255,0.14)',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(242,113,33,0.55)',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(233,64,205,0.65)',
borderWidth: '2px',
},
'& .MuiSelect-icon': {
color: 'rgba(255,255,255,0.75)',
},
}}
>
{caseServers.map((ip) => (
<MenuItem key={ip} value={ip}>
{translateServer(`Server ${ip}`)}
</MenuItem>
))}
</Select>
</FormControl>
)}
</Box>
{!isOnline ? (
<Typography variant="body1" color="error" sx={{ mb: 2 }}>
Для открытия кейсов вам необходимо находиться на одном из
серверов игры. Зайдите в игру и нажмите кнопку «Обновить».
</Typography>
) : casesLoading ? (
<FullScreenLoader
fullScreen={false}
message="Загрузка кейсов..."
/>
{casesLoading ? (
<FullScreenLoader fullScreen={false} message="Загрузка кейсов..." />
) : cases.length > 0 ? (
<Grid container spacing={2} sx={{ mb: 4 }}>
{cases.map((c) => (
{filteredCases.map((c) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={c.id}>
<ShopItem
type="case"
@ -625,7 +715,7 @@ export default function Shop() {
price={c.price}
itemsCount={c.items_count}
isOpening={isOpening && selectedCase?.id === c.id}
disabled={!isOnline || isOpening}
disabled={isOpening || !selectedCaseServerIp}
onClick={() => handleOpenCase(c)}
/>
</Grid>