add inventory and change cases
This commit is contained in:
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -33,6 +33,7 @@ export default function PageHeader() {
|
||||
path === '/registration' ||
|
||||
path === '/marketplace' ||
|
||||
path === '/profile' ||
|
||||
path === '/inventory' ||
|
||||
path.startsWith('/launch')
|
||||
) {
|
||||
return { title: '', subtitle: '', hidden: true };
|
||||
|
||||
@ -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();
|
||||
|
||||
359
src/renderer/pages/Inventory.tsx
Normal file
359
src/renderer/pages/Inventory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -349,8 +349,8 @@ export default function Profile() {
|
||||
}}
|
||||
>
|
||||
<SkinViewer
|
||||
width={400}
|
||||
height={465}
|
||||
width={340}
|
||||
height={405}
|
||||
skinUrl={skin}
|
||||
capeUrl={cape}
|
||||
walkingSpeed={walkingSpeed}
|
||||
|
||||
@ -357,6 +357,7 @@ const Settings = () => {
|
||||
pb: '2vw',
|
||||
width: '95%',
|
||||
boxSizing: 'border-box',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<CustomNotification
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user