add inventory and change cases
This commit is contained in:
@ -247,7 +247,7 @@ const createWindow = async () => {
|
|||||||
width: 1024,
|
width: 1024,
|
||||||
height: 850,
|
height: 850,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
resizable: true,
|
resizable: false,
|
||||||
frame: false,
|
frame: false,
|
||||||
icon: getAssetPath('popa-popa.png'),
|
icon: getAssetPath('popa-popa.png'),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
|
|||||||
@ -34,13 +34,13 @@ body.no-blur .glass-ui {
|
|||||||
/* SETTINGS NO-BLUR */
|
/* SETTINGS NO-BLUR */
|
||||||
|
|
||||||
/* SETTINGS REDUCE-MOTION */
|
/* SETTINGS REDUCE-MOTION */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
/* @media (prefers-reduced-motion: reduce) {
|
||||||
* {
|
* {
|
||||||
animation-duration: 0.001ms !important;
|
animation-duration: 0.001ms !important;
|
||||||
animation-iteration-count: 1 !important;
|
animation-iteration-count: 1 !important;
|
||||||
transition-duration: 0.001ms !important;
|
transition-duration: 0.001ms !important;
|
||||||
}
|
}
|
||||||
}
|
} */
|
||||||
|
|
||||||
body.reduce-motion *,
|
body.reduce-motion *,
|
||||||
body.reduce-motion *::before,
|
body.reduce-motion *::before,
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import { useLocation } from 'react-router-dom';
|
|||||||
import DailyReward from './pages/DailyReward';
|
import DailyReward from './pages/DailyReward';
|
||||||
import DailyQuests from './pages/DailyQuests';
|
import DailyQuests from './pages/DailyQuests';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
|
import Inventory from './pages/Inventory';
|
||||||
import { TrayBridge } from './utils/TrayBridge';
|
import { TrayBridge } from './utils/TrayBridge';
|
||||||
|
|
||||||
const AuthCheck = ({ children }: { children: ReactNode }) => {
|
const AuthCheck = ({ children }: { children: ReactNode }) => {
|
||||||
@ -287,6 +288,14 @@ const AppLayout = () => {
|
|||||||
</AuthCheck>
|
</AuthCheck>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/inventory"
|
||||||
|
element={
|
||||||
|
<AuthCheck>
|
||||||
|
<Inventory />
|
||||||
|
</AuthCheck>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/daily"
|
path="/daily"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@ -426,6 +426,8 @@ export async function toggleBonusActivation(
|
|||||||
export interface CaseItemMeta {
|
export interface CaseItemMeta {
|
||||||
display_name?: string | null;
|
display_name?: string | null;
|
||||||
lore?: string[] | null;
|
lore?: string[] | null;
|
||||||
|
enchants?: Record<string, number> | null;
|
||||||
|
durability?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CaseItem {
|
export interface CaseItem {
|
||||||
@ -443,7 +445,7 @@ export interface Case {
|
|||||||
description?: string;
|
description?: string;
|
||||||
price: number;
|
price: number;
|
||||||
image_url?: string;
|
image_url?: string;
|
||||||
server_ids?: string[];
|
server_ips?: string[];
|
||||||
items_count?: number;
|
items_count?: number;
|
||||||
items?: CaseItem[];
|
items?: CaseItem[];
|
||||||
}
|
}
|
||||||
@ -457,52 +459,34 @@ export interface OpenCaseResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchCases(): Promise<Case[]> {
|
export async function fetchCases(): Promise<Case[]> {
|
||||||
const response = await fetch(`${API_BASE_URL}/cases`);
|
const response = await fetch(`${API_BASE_URL}/cases/`);
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error('Не удалось получить список кейсов');
|
||||||
throw new Error('Не удалось получить список кейсов');
|
|
||||||
}
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если у тебя есть отдельный эндпоинт деталей кейса, можно использовать это:
|
|
||||||
export async function fetchCase(case_id: string): Promise<Case> {
|
export async function fetchCase(case_id: string): Promise<Case> {
|
||||||
const response = await fetch(`${API_BASE_URL}/cases/${case_id}`);
|
const response = await fetch(`${API_BASE_URL}/cases/${case_id}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error('Не удалось получить информацию о кейсе');
|
||||||
throw new Error('Не удалось получить информацию о кейсе');
|
|
||||||
}
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openCase(
|
export async function openCase(
|
||||||
case_id: string,
|
case_id: string,
|
||||||
username: string,
|
username: string,
|
||||||
server_id: string,
|
server_ip: string,
|
||||||
): Promise<OpenCaseResponse> {
|
): Promise<OpenCaseResponse> {
|
||||||
// Формируем URL с query-параметрами, как любит текущий бэкенд
|
|
||||||
const url = new URL(`${API_BASE_URL}/cases/${case_id}/open`);
|
const url = new URL(`${API_BASE_URL}/cases/${case_id}/open`);
|
||||||
url.searchParams.append('username', username);
|
url.searchParams.set('username', username);
|
||||||
url.searchParams.append('server_id', server_id);
|
url.searchParams.set('server_ip', server_ip);
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
const response = await fetch(url.toString(), { method: 'POST' });
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let msg = 'Не удалось открыть кейс';
|
let msg = 'Не удалось открыть кейс';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
if (errorData.message) {
|
msg = errorData.message || errorData.detail || msg;
|
||||||
msg = errorData.message;
|
} catch {}
|
||||||
} 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, оставляем дефолтное сообщение
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(msg);
|
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 {
|
export interface DailyStatusResponse {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
|
|||||||
@ -33,6 +33,7 @@ export default function PageHeader() {
|
|||||||
path === '/registration' ||
|
path === '/registration' ||
|
||||||
path === '/marketplace' ||
|
path === '/marketplace' ||
|
||||||
path === '/profile' ||
|
path === '/profile' ||
|
||||||
|
path === '/inventory' ||
|
||||||
path.startsWith('/launch')
|
path.startsWith('/launch')
|
||||||
) {
|
) {
|
||||||
return { title: '', subtitle: '', hidden: true };
|
return { title: '', subtitle: '', hidden: true };
|
||||||
|
|||||||
@ -21,6 +21,8 @@ import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
|
|||||||
import PersonIcon from '@mui/icons-material/Person';
|
import PersonIcon from '@mui/icons-material/Person';
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
import { useTheme } from '@mui/material/styles';
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
import CategoryIcon from '@mui/icons-material/Category';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
electron: {
|
electron: {
|
||||||
@ -600,6 +602,19 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
|||||||
<EmojiEventsIcon sx={{ fontSize: '2vw' }} /> Ежедневная награда
|
<EmojiEventsIcon sx={{ fontSize: '2vw' }} /> Ежедневная награда
|
||||||
</MenuItem>
|
</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
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleAvatarMenuClose();
|
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
|
<SkinViewer
|
||||||
width={400}
|
width={340}
|
||||||
height={465}
|
height={405}
|
||||||
skinUrl={skin}
|
skinUrl={skin}
|
||||||
capeUrl={cape}
|
capeUrl={cape}
|
||||||
walkingSpeed={walkingSpeed}
|
walkingSpeed={walkingSpeed}
|
||||||
|
|||||||
@ -357,6 +357,7 @@ const Settings = () => {
|
|||||||
pb: '2vw',
|
pb: '2vw',
|
||||||
width: '95%',
|
width: '95%',
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
|
overflowY: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CustomNotification
|
<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 {
|
import {
|
||||||
Cape,
|
Cape,
|
||||||
fetchCapes,
|
fetchCapes,
|
||||||
@ -30,6 +33,7 @@ import { playBuySound, primeSounds } from '../utils/sounds';
|
|||||||
import CustomNotification from '../components/Notifications/CustomNotification';
|
import CustomNotification from '../components/Notifications/CustomNotification';
|
||||||
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||||||
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
|
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
|
||||||
|
import { translateServer } from '../utils/serverTranslator';
|
||||||
|
|
||||||
function getRarityByWeight(
|
function getRarityByWeight(
|
||||||
weight?: number,
|
weight?: number,
|
||||||
@ -66,6 +70,8 @@ export default function Shop() {
|
|||||||
|
|
||||||
const [playerSkinUrl, setPlayerSkinUrl] = useState<string>('');
|
const [playerSkinUrl, setPlayerSkinUrl] = useState<string>('');
|
||||||
|
|
||||||
|
const [selectedCaseServerIp, setSelectedCaseServerIp] = useState<string>('');
|
||||||
|
|
||||||
// Уведомления
|
// Уведомления
|
||||||
|
|
||||||
const [notifOpen, setNotifOpen] = useState(false);
|
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(
|
const availableCapes = storeCapes.filter(
|
||||||
(storeCape) =>
|
(storeCape) =>
|
||||||
@ -352,62 +380,72 @@ export default function Shop() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenCase = async (caseData: Case) => {
|
const handleOpenCase = async (caseData: Case) => {
|
||||||
if (!username) {
|
if (!username) {
|
||||||
if (!isNotificationsEnabled()) return;
|
if (!isNotificationsEnabled()) return;
|
||||||
setNotifMsg('Не найдено имя игрока. Авторизуйтесь в лаунчере!');
|
setNotifMsg('Не найдено имя игрока. Авторизуйтесь в лаунчере!');
|
||||||
setNotifSeverity('error');
|
setNotifSeverity('error');
|
||||||
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
||||||
setNotifOpen(true);
|
setNotifOpen(true);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isOnline || !playerServer) {
|
if (!selectedCaseServerIp) {
|
||||||
if (!isNotificationsEnabled()) return;
|
if (!isNotificationsEnabled()) return;
|
||||||
setNotifMsg('Для открытия кейсов необходимо находиться на сервере в игре!');
|
setNotifMsg('Выберите сервер для открытия кейса!');
|
||||||
setNotifSeverity('error');
|
setNotifSeverity('warning');
|
||||||
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
||||||
setNotifOpen(true);
|
setNotifOpen(true);
|
||||||
return;
|
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 {
|
if (isOpening) return;
|
||||||
setIsOpening(true);
|
|
||||||
|
|
||||||
// 1. получаем полный кейс
|
try {
|
||||||
const fullCase = await fetchCase(caseData.id);
|
setIsOpening(true);
|
||||||
const caseItems: CaseItem[] = fullCase.items || [];
|
|
||||||
setSelectedCase(fullCase);
|
|
||||||
|
|
||||||
// 2. открываем кейс на бэке
|
const fullCase = await fetchCase(caseData.id);
|
||||||
const result = await openCase(fullCase.id, username, playerServer.id);
|
const caseItems: CaseItem[] = fullCase.items || [];
|
||||||
|
setSelectedCase(fullCase);
|
||||||
|
|
||||||
// 3. сохраняем данные для рулетки
|
// ✅ открываем на выбранном сервере (даже если игрок не на сервере)
|
||||||
setRouletteCaseItems(caseItems);
|
const result = await openCase(fullCase.id, username, selectedCaseServerIp);
|
||||||
setRouletteReward(result.reward);
|
|
||||||
setRouletteOpen(true);
|
|
||||||
playBuySound();
|
|
||||||
|
|
||||||
setIsOpening(false);
|
setRouletteCaseItems(caseItems);
|
||||||
|
setRouletteReward(result.reward);
|
||||||
|
setRouletteOpen(true);
|
||||||
|
playBuySound();
|
||||||
|
|
||||||
// 4. уведомление
|
if (!isNotificationsEnabled()) return;
|
||||||
if (!isNotificationsEnabled()) return;
|
setNotifMsg('Кейс открыт!');
|
||||||
setNotifMsg('Кейс открыт!');
|
setNotifSeverity('success');
|
||||||
setNotifSeverity('success');
|
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
||||||
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
setNotifOpen(true);
|
||||||
setNotifOpen(true);
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error('Ошибка при открытии кейса:', error);
|
||||||
console.error('Ошибка при открытии кейса:', error);
|
|
||||||
|
|
||||||
setIsOpening(false);
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg(String(error instanceof Error ? error.message : 'Ошибка при открытии кейса!'));
|
||||||
if (!isNotificationsEnabled()) return;
|
setNotifSeverity('error');
|
||||||
setNotifMsg('Ошибка при открытии кейса!');
|
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
||||||
setNotifSeverity('error');
|
setNotifOpen(true);
|
||||||
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
} finally {
|
||||||
setNotifOpen(true);
|
setIsOpening(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseNotification = () => {
|
const handleCloseNotification = () => {
|
||||||
setNotification((prev) => ({ ...prev, open: false }));
|
setNotification((prev) => ({ ...prev, open: false }));
|
||||||
@ -570,8 +608,6 @@ export default function Shop() {
|
|||||||
>
|
>
|
||||||
Кейсы
|
Кейсы
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{!isOnline && (
|
|
||||||
<Button
|
<Button
|
||||||
disableRipple
|
disableRipple
|
||||||
disableFocusRipple
|
disableFocusRipple
|
||||||
@ -599,22 +635,76 @@ export default function Shop() {
|
|||||||
>
|
>
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</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>
|
</Box>
|
||||||
|
|
||||||
{!isOnline ? (
|
{casesLoading ? (
|
||||||
<Typography variant="body1" color="error" sx={{ mb: 2 }}>
|
<FullScreenLoader fullScreen={false} message="Загрузка кейсов..." />
|
||||||
Для открытия кейсов вам необходимо находиться на одном из
|
|
||||||
серверов игры. Зайдите в игру и нажмите кнопку «Обновить».
|
|
||||||
</Typography>
|
|
||||||
) : casesLoading ? (
|
|
||||||
<FullScreenLoader
|
|
||||||
fullScreen={false}
|
|
||||||
message="Загрузка кейсов..."
|
|
||||||
/>
|
|
||||||
) : cases.length > 0 ? (
|
) : cases.length > 0 ? (
|
||||||
<Grid container spacing={2} sx={{ mb: 4 }}>
|
<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}>
|
<Grid item xs={12} sm={6} md={4} lg={3} key={c.id}>
|
||||||
<ShopItem
|
<ShopItem
|
||||||
type="case"
|
type="case"
|
||||||
@ -625,7 +715,7 @@ export default function Shop() {
|
|||||||
price={c.price}
|
price={c.price}
|
||||||
itemsCount={c.items_count}
|
itemsCount={c.items_count}
|
||||||
isOpening={isOpening && selectedCase?.id === c.id}
|
isOpening={isOpening && selectedCase?.id === c.id}
|
||||||
disabled={!isOnline || isOpening}
|
disabled={isOpening || !selectedCaseServerIp}
|
||||||
onClick={() => handleOpenCase(c)}
|
onClick={() => handleOpenCase(c)}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
Reference in New Issue
Block a user