1818 lines
62 KiB
TypeScript
1818 lines
62 KiB
TypeScript
// src/renderer/pages/Marketplace.tsx
|
||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import * as React from 'react';
|
||
import {
|
||
Box,
|
||
Typography,
|
||
Button,
|
||
Grid,
|
||
Card,
|
||
CardContent,
|
||
CardMedia,
|
||
Pagination,
|
||
Tabs,
|
||
Tab,
|
||
Select,
|
||
FormControl,
|
||
MenuItem,
|
||
Chip,
|
||
Divider,
|
||
Dialog,
|
||
DialogTitle,
|
||
DialogContent,
|
||
DialogActions,
|
||
TextField,
|
||
} from '@mui/material';
|
||
|
||
import {
|
||
buyItem,
|
||
fetchMarketplace,
|
||
MarketplaceResponse,
|
||
MarketplaceItemResponse,
|
||
Server,
|
||
fetchActiveServers,
|
||
fetchMyMarketplaceItems,
|
||
updateMarketplaceItemPrice,
|
||
cancelMarketplaceItemSale,
|
||
} from '../api';
|
||
import PlayerInventory, { type PlayerInventoryHandle } from '../components/PlayerInventory';
|
||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||
import CustomNotification from '../components/Notifications/CustomNotification';
|
||
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||
|
||
import { getPlayerServer } from '../utils/playerOnlineCheck';
|
||
import { playBuySound } from '../utils/sounds';
|
||
import { translateServer } from '../utils/serverTranslator';
|
||
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
|
||
import { NONAME } from 'dns';
|
||
import GradientTextField from '../components/GradientTextField';
|
||
import CloseIcon from '@mui/icons-material/Close';
|
||
import IconButton from '@mui/material/IconButton';
|
||
import type { SxProps, Theme } from '@mui/material/styles';
|
||
import { getWsBaseUrl } from '../realtime/wsBase';
|
||
import { formatEnchants } from '../utils/itemTranslator';
|
||
import { translateMetaKey, formatMetaValue } from '../utils/itemTranslator';
|
||
|
||
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}
|
||
id={`marketplace-tabpanel-${index}`}
|
||
aria-labelledby={`marketplace-tab-${index}`}
|
||
{...other}
|
||
>
|
||
{value === index && <Box sx={{ pt: '1.4vw' }}>{children}</Box>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const GRADIENT =
|
||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||
|
||
const GLASS_BG =
|
||
'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)';
|
||
|
||
const GLASS_BORDER = '1px solid rgba(255,255,255,0.08)';
|
||
const GLASS_SHADOW = '0 1.2vw 3.2vw rgba(0,0,0,0.55)';
|
||
|
||
function upsertIntoList<T extends { id: string }>(list: T[], item: T) {
|
||
const idx = list.findIndex((x) => x.id === item.id);
|
||
if (idx === -1) return [item, ...list];
|
||
const copy = list.slice();
|
||
copy[idx] = item;
|
||
return copy;
|
||
}
|
||
|
||
function removeFromList<T extends { id: string }>(list: T[], id: string) {
|
||
return list.filter((x) => x.id !== id);
|
||
}
|
||
|
||
export default function Marketplace() {
|
||
const [marketLoading, setMarketLoading] = useState<boolean>(false);
|
||
|
||
const [isOnline, setIsOnline] = useState<boolean>(false);
|
||
const [username, setUsername] = useState<string>('');
|
||
const [playerServer, setPlayerServer] = useState<Server | null>(null);
|
||
|
||
const [marketItems, setMarketItems] = useState<MarketplaceResponse | null>(null);
|
||
const [page, setPage] = useState<number>(1);
|
||
const [totalPages, setTotalPages] = useState<number>(1);
|
||
|
||
const [tabValue, setTabValue] = useState<number>(0);
|
||
|
||
// notifications
|
||
const [notifOpen, setNotifOpen] = useState(false);
|
||
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
|
||
const [notifSeverity, setNotifSeverity] = useState<
|
||
'success' | 'info' | 'warning' | 'error'
|
||
>('info');
|
||
|
||
const [notifPos, setNotifPos] = useState<NotificationPosition>({
|
||
vertical: 'bottom',
|
||
horizontal: 'center',
|
||
});
|
||
|
||
// servers
|
||
const [servers, setServers] = useState<Server[]>([]);
|
||
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
|
||
|
||
// “тихий” лоадер статуса
|
||
const [statusLoading, setStatusLoading] = useState<boolean>(false);
|
||
|
||
const [myItems, setMyItems] = useState<MarketplaceResponse | null>(null);
|
||
const [myLoading, setMyLoading] = useState(false);
|
||
const [myPage, setMyPage] = useState(1);
|
||
const [myTotalPages, setMyTotalPages] = useState(1);
|
||
|
||
const [editPriceOpen, setEditPriceOpen] = useState(false);
|
||
const [editItem, setEditItem] = useState<MarketplaceItemResponse | null>(null);
|
||
const [editPriceValue, setEditPriceValue] = useState<string>('');
|
||
const [description, setDescription] = useState('');
|
||
|
||
const [removeOpen, setRemoveOpen] = useState(false);
|
||
const [removeItem, setRemoveItem] = useState<MarketplaceItemResponse | null>(null);
|
||
|
||
const inventoryRef = useRef<PlayerInventoryHandle | null>(null);
|
||
|
||
const [metaDialogOpen, setMetaDialogOpen] = useState(false);
|
||
const [metaItem, setMetaItem] = useState<MarketplaceItemResponse | null>(null);
|
||
const [metaSearch, setMetaSearch] = useState('');
|
||
|
||
const openMetaDialog = (item: MarketplaceItemResponse) => {
|
||
setMetaItem(item);
|
||
setMetaDialogOpen(true);
|
||
};
|
||
|
||
const closeMetaDialog = () => {
|
||
setMetaDialogOpen(false);
|
||
setMetaItem(null);
|
||
};
|
||
|
||
const openEditPrice = (item: MarketplaceItemResponse) => {
|
||
setEditItem(item);
|
||
setEditPriceValue(String(item.price ?? ''));
|
||
setDescription(item.description ?? '');
|
||
setEditPriceOpen(true);
|
||
};
|
||
|
||
const openRemove = (item: MarketplaceItemResponse) => {
|
||
setRemoveItem(item);
|
||
setRemoveOpen(true);
|
||
};
|
||
|
||
const loadMyItems = async (serverIp: string, pageNumber: number) => {
|
||
if (!username) return;
|
||
|
||
try {
|
||
setMyLoading(true);
|
||
const data = await fetchMyMarketplaceItems(username, serverIp, pageNumber, 10);
|
||
setMyItems(data);
|
||
setMyPage(data.page);
|
||
setMyTotalPages(data.pages);
|
||
} catch (e) {
|
||
console.error('Ошибка при загрузке моих товаров:', e);
|
||
setMyItems(null);
|
||
} finally {
|
||
setMyLoading(false);
|
||
}
|
||
};
|
||
|
||
type MarketListedPayload = { serverIp: string; item: MarketplaceItemResponse };
|
||
type MarketSoldPayload = { serverIp: string; itemId: string };
|
||
type MarketPricePayload = { serverIp: string; item: MarketplaceItemResponse };
|
||
type MarketCancelledPayload = { serverIp: string; itemId: string };
|
||
|
||
useEffect(() => {
|
||
if (!selectedServer) return;
|
||
|
||
const serverIp = selectedServer.ip;
|
||
|
||
const base = 'wss://minecraft.api.popa-popa.ru'
|
||
const wsUrl = `${base}/ws/marketplace?server_ip=${encodeURIComponent(serverIp)}`;
|
||
|
||
const ws = new WebSocket(wsUrl);
|
||
|
||
const ping = window.setInterval(() => {
|
||
if (ws.readyState === WebSocket.OPEN) ws.send('ping');
|
||
}, 25000);
|
||
|
||
ws.onmessage = (ev) => {
|
||
let msg: any;
|
||
try {
|
||
msg = JSON.parse(ev.data);
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
if (msg.server_ip !== serverIp && msg.serverIp !== serverIp) return;
|
||
|
||
switch (msg.event) {
|
||
case 'market:item_listed': {
|
||
const item: MarketplaceItemResponse = msg.item;
|
||
setMarketItems((prev) => {
|
||
if (!prev) return prev;
|
||
if (prev.page !== 1) return prev;
|
||
return { ...prev, items: upsertIntoList(prev.items, item) };
|
||
});
|
||
|
||
if (item?.seller_name === username) {
|
||
setMyItems((prev) => {
|
||
if (!prev) return prev;
|
||
if (prev.page !== 1) return prev;
|
||
return { ...prev, items: upsertIntoList(prev.items, item) };
|
||
});
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'market:item_sold': {
|
||
const itemId: string = msg.item_id ?? msg.itemId;
|
||
setMarketItems((prev) => (prev ? { ...prev, items: removeFromList(prev.items, itemId) } : prev));
|
||
setMyItems((prev) => (prev ? { ...prev, items: removeFromList(prev.items, itemId) } : prev));
|
||
break;
|
||
}
|
||
|
||
case 'market:item_cancelled': {
|
||
const itemId: string = msg.item_id ?? msg.itemId;
|
||
setMarketItems((prev) => (prev ? { ...prev, items: removeFromList(prev.items, itemId) } : prev));
|
||
setMyItems((prev) => (prev ? { ...prev, items: removeFromList(prev.items, itemId) } : prev));
|
||
break;
|
||
}
|
||
|
||
case 'market:item_price_updated': {
|
||
const item: MarketplaceItemResponse = msg.item;
|
||
setMarketItems((prev) => (prev ? { ...prev, items: upsertIntoList(prev.items, item) } : prev));
|
||
setMyItems((prev) => (prev ? { ...prev, items: upsertIntoList(prev.items, item) } : prev));
|
||
break;
|
||
}
|
||
}
|
||
};
|
||
|
||
ws.onerror = (e) => {
|
||
console.error('Marketplace WS error:', e);
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
window.clearInterval(ping);
|
||
};
|
||
|
||
return () => {
|
||
window.clearInterval(ping);
|
||
ws.close();
|
||
};
|
||
}, [selectedServer?.ip, username]);
|
||
|
||
useEffect(() => {
|
||
if (tabValue !== 2) return;
|
||
if (!selectedServer) return;
|
||
loadMyItems(selectedServer.ip, 1);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [tabValue, selectedServer, username]);
|
||
|
||
const showNotification = (
|
||
message: React.ReactNode,
|
||
severity: 'success' | 'info' | 'warning' | 'error' = 'info',
|
||
position: NotificationPosition = getNotifPositionFromSettings(),
|
||
) => {
|
||
if (!isNotificationsEnabled()) return;
|
||
setNotifMsg(message);
|
||
setNotifSeverity(severity);
|
||
setNotifPos(position);
|
||
setNotifOpen(true);
|
||
};
|
||
|
||
// Получаем username из localStorage
|
||
useEffect(() => {
|
||
const savedConfig = localStorage.getItem('launcher_config');
|
||
if (savedConfig) {
|
||
const config = JSON.parse(savedConfig);
|
||
if (config.username) setUsername(config.username);
|
||
}
|
||
}, []);
|
||
|
||
// load servers
|
||
useEffect(() => {
|
||
const loadServers = async () => {
|
||
try {
|
||
const active = await fetchActiveServers();
|
||
setServers(active);
|
||
if (active.length && !selectedServer) setSelectedServer(active[0]);
|
||
} catch (e) {
|
||
console.error('Не удалось загрузить список серверов:', e);
|
||
setServers([]);
|
||
setSelectedServer(null);
|
||
}
|
||
};
|
||
|
||
loadServers();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// check player status
|
||
const checkPlayerStatus = async () => {
|
||
if (!username) return;
|
||
|
||
try {
|
||
setStatusLoading(true);
|
||
const { online, server } = await getPlayerServer(username);
|
||
setIsOnline(online);
|
||
setPlayerServer(server);
|
||
} catch (error) {
|
||
console.error('Ошибка при проверке онлайн-статуса:', error);
|
||
setIsOnline(false);
|
||
setPlayerServer(null);
|
||
} finally {
|
||
setStatusLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSavePrice = async () => {
|
||
if (!username || !selectedServer || !editItem) return;
|
||
|
||
const price = Number(editPriceValue);
|
||
if (!price || price <= 0) {
|
||
showNotification('Цена должна быть числом больше 0', 'warning');
|
||
return;
|
||
}
|
||
|
||
// ДОДЕЛАТЬ - ИЗМЕНЕНИЕ ОПИСАНИЯ
|
||
// const payload: any = {
|
||
// itemId: editItem.id,
|
||
// price,
|
||
// };
|
||
|
||
// if (description.trim() !== '') {
|
||
// payload.description = description.trim();
|
||
// } else {
|
||
// payload.description = null;
|
||
// }
|
||
|
||
try {
|
||
await updateMarketplaceItemPrice(username, editItem.id, price);
|
||
|
||
showNotification('Цена обновлена', 'success');
|
||
|
||
setEditPriceOpen(false);
|
||
setEditItem(null);
|
||
|
||
// обновляем “Мои товары” и общий рынок
|
||
await loadMyItems(selectedServer.ip, myPage);
|
||
await loadMarketItems(selectedServer.ip, page);
|
||
} catch (e) {
|
||
showNotification(
|
||
e instanceof Error ? e.message : 'Ошибка при изменении цены',
|
||
'error',
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleRemoveItem = async () => {
|
||
if (!username || !selectedServer || !removeItem) return;
|
||
|
||
if (!canBuyOnSelectedServer) {
|
||
showNotification(
|
||
'Для снятия товара с продажи нужно быть на сервере.',
|
||
'error',
|
||
);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await cancelMarketplaceItemSale(username, removeItem.id);
|
||
|
||
showNotification('Товар снят с продажи', 'success');
|
||
|
||
setRemoveOpen(false);
|
||
setRemoveItem(null);
|
||
|
||
// обновляем “Мои товары” и общий рынок
|
||
await loadMyItems(selectedServer.ip, myPage);
|
||
await loadMarketItems(selectedServer.ip, page);
|
||
} catch (e) {
|
||
showNotification(
|
||
e instanceof Error ? e.message : 'Ошибка при снятии товара',
|
||
'error',
|
||
);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (username) checkPlayerStatus();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [username]);
|
||
|
||
// market loader
|
||
const loadMarketItems = async (serverIp: string, pageNumber: number) => {
|
||
try {
|
||
setMarketLoading(true);
|
||
const marketData = await fetchMarketplace(serverIp, pageNumber, 12);
|
||
setMarketItems(marketData);
|
||
setPage(marketData.page);
|
||
setTotalPages(marketData.pages);
|
||
} catch (error) {
|
||
console.error('Ошибка при загрузке предметов рынка:', error);
|
||
setMarketItems(null);
|
||
} finally {
|
||
setMarketLoading(false);
|
||
}
|
||
};
|
||
|
||
// when server changes -> load market
|
||
useEffect(() => {
|
||
if (selectedServer) {
|
||
loadMarketItems(selectedServer.ip, 1);
|
||
} else {
|
||
setMarketItems(null);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [selectedServer]);
|
||
|
||
const handlePageChange = (_event: React.ChangeEvent<unknown>, newPage: number) => {
|
||
if (selectedServer) loadMarketItems(selectedServer.ip, newPage);
|
||
};
|
||
|
||
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||
setTabValue(newValue);
|
||
};
|
||
|
||
const metaKeyMatchesSearch = (key: string, search: string) => {
|
||
if (!search) return true;
|
||
|
||
const s = search.toLowerCase();
|
||
|
||
const original = key.toLowerCase();
|
||
const translated = translateMetaKey(key).toLowerCase();
|
||
|
||
return (
|
||
original.includes(s) ||
|
||
translated.includes(s)
|
||
);
|
||
};
|
||
|
||
const handleBuyItem = async (itemId: string) => {
|
||
try {
|
||
if (!username) return;
|
||
|
||
// 1) при покупке — проверяем где игрок
|
||
const { online, server } = await getPlayerServer(username);
|
||
setIsOnline(online);
|
||
setPlayerServer(server);
|
||
|
||
// 2) если не онлайн — запрещаем покупку
|
||
if (!online || !server) {
|
||
showNotification(
|
||
'Чтобы покупать предметы, нужно находиться на сервере игры.',
|
||
'warning',
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 3) если онлайн, но НЕ на выбранном сервере — запрещаем покупку
|
||
if (!selectedServer || server.id !== selectedServer.id) {
|
||
showNotification(
|
||
<>
|
||
Вы сейчас на сервере <b>{translateServer(server.name)}</b>. <br />
|
||
Для покупки переключитесь на него в списке серверов или зайдите на{' '}
|
||
<b>{translateServer(selectedServer?.name ?? '')}</b>.
|
||
</>,
|
||
'info',
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 4) покупаем
|
||
const result = await buyItem(username, itemId);
|
||
|
||
playBuySound();
|
||
|
||
showNotification(
|
||
result.message || 'Предмет успешно куплен! Он будет добавлен в ваш инвентарь.',
|
||
'success',
|
||
);
|
||
|
||
// обновляем список предметов
|
||
loadMarketItems(selectedServer.ip, page);
|
||
} catch (error) {
|
||
console.error('Ошибка при покупке предмета:', error);
|
||
showNotification(
|
||
error instanceof Error ? error.message : 'Ошибка при покупке предмета',
|
||
'error',
|
||
);
|
||
}
|
||
};
|
||
|
||
const canBuyOnSelectedServer = useMemo(() => {
|
||
return !!selectedServer && !!playerServer && isOnline && playerServer.id === selectedServer.id;
|
||
}, [selectedServer, playerServer, isOnline]);
|
||
|
||
const PRICE_FIELD_SX: SxProps<Theme> = {
|
||
mt: 1.2,
|
||
|
||
'& .MuiInputLabel-root': {
|
||
color: 'rgba(255,255,255,0.72)',
|
||
fontFamily: 'Benzin-Bold',
|
||
letterSpacing: 0.3,
|
||
textTransform: 'uppercase',
|
||
},
|
||
'& .MuiInputLabel-root.Mui-focused': {
|
||
color: 'rgba(255,255,255,0.92)',
|
||
},
|
||
|
||
'& .MuiOutlinedInput-root': {
|
||
position: 'relative',
|
||
borderRadius: '1.2vw',
|
||
overflow: 'hidden',
|
||
|
||
background:
|
||
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.16), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.92)',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
boxShadow: '0 1.2vw 3.0vw rgba(0,0,0,0.55)',
|
||
backdropFilter: 'blur(14px)',
|
||
|
||
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
|
||
|
||
'& input': {
|
||
fontFamily: 'Benzin-Bold',
|
||
fontSize: '1.05rem',
|
||
padding: '1.05vw 1.1vw',
|
||
color: 'rgba(255,255,255,0.95)',
|
||
},
|
||
|
||
transition: 'transform 0.18s ease, filter 0.18s ease, border-color 0.25s ease, box-shadow 0.25s ease',
|
||
'&:hover': {
|
||
borderColor: 'rgba(200, 33, 242, 0.35)',
|
||
boxShadow: '0 1.2vw 3.2vw rgba(53, 3, 66, 0.45)',
|
||
},
|
||
'&.Mui-focused': {
|
||
borderColor: 'rgba(255,255,255,0.18)',
|
||
filter: 'brightness(1.03)',
|
||
},
|
||
|
||
'&:after': {
|
||
content: '""',
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
height: '0.18vw',
|
||
borderRadius: '999px',
|
||
background: GRADIENT,
|
||
opacity: 0.92,
|
||
pointerEvents: 'none',
|
||
},
|
||
},
|
||
|
||
'& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button': {
|
||
filter: 'invert(1) opacity(0.65)',
|
||
},
|
||
};
|
||
|
||
const GLASS_PAPER_SX: SxProps<Theme> = {
|
||
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',
|
||
backdropFilter: 'blur(16px)',
|
||
};
|
||
|
||
const DIALOG_TITLE_SX: SxProps<Theme> = {
|
||
fontFamily: 'Benzin-Bold',
|
||
pr: 6,
|
||
position: 'relative',
|
||
};
|
||
|
||
const CLOSE_BTN_SX: SxProps<Theme> = {
|
||
position: 'absolute',
|
||
top: 10,
|
||
right: 10,
|
||
color: 'rgba(255,255,255,0.9)',
|
||
border: '1px solid rgba(255,255,255,0.12)',
|
||
background: 'rgba(255,255,255,0.06)',
|
||
backdropFilter: 'blur(12px)',
|
||
'&:hover': { transform: 'scale(1.05)', background: 'rgba(255,255,255,0.10)' },
|
||
transition: 'all 0.2s ease',
|
||
};
|
||
|
||
const DIALOG_DIVIDERS_SX: SxProps<Theme> = {
|
||
borderColor: 'rgba(255,255,255,0.10)',
|
||
};
|
||
|
||
const handleRefreshAll = async () => {
|
||
await checkPlayerStatus();
|
||
|
||
if (!selectedServer) return;
|
||
|
||
await loadMarketItems(selectedServer.ip, 1);
|
||
|
||
if (tabValue === 2) {
|
||
await loadMyItems(selectedServer.ip, myPage);
|
||
}
|
||
if (tabValue === 1) {
|
||
await inventoryRef.current?.refresh();
|
||
}
|
||
};
|
||
|
||
const getItemMeta = (item: MarketplaceItemResponse) => {
|
||
return item.item_data?.meta ?? null;
|
||
};
|
||
|
||
const extractEnchants = (meta: Record<string, any>) => {
|
||
return meta?.enchants && typeof meta.enchants === 'object'
|
||
? meta.enchants
|
||
: null;
|
||
};
|
||
|
||
const hasItemMeta = (item: MarketplaceItemResponse) => {
|
||
const meta = item.item_data;
|
||
// if (!meta) return false;
|
||
|
||
// базовые поля, которые НЕ считаем метой
|
||
const baseKeys = ['slot', 'material', 'amount'];
|
||
|
||
return Object.keys(meta).some((key) => !baseKeys.includes(key));
|
||
};
|
||
|
||
const hasItemDetails = (item: MarketplaceItemResponse) => {
|
||
return Boolean(
|
||
hasItemMeta(item) || item.description
|
||
);
|
||
};
|
||
|
||
|
||
const statusChip = useMemo(() => {
|
||
if (statusLoading) {
|
||
return (
|
||
<Chip
|
||
size="small"
|
||
label="Проверяем онлайн..."
|
||
sx={{
|
||
height: '1.7vw',
|
||
borderRadius: '999px',
|
||
color: 'rgba(255,255,255,0.9)',
|
||
fontWeight: 900,
|
||
background: 'rgba(255,255,255,0.08)',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
backdropFilter: 'blur(12px)',
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (isOnline && playerServer) {
|
||
return (
|
||
<Chip
|
||
size="small"
|
||
label={`Вы онлайн: ${translateServer(playerServer.name)}`}
|
||
sx={{
|
||
height: '1.7vw',
|
||
borderRadius: '999px',
|
||
color: 'rgba(156,255,198,0.95)',
|
||
fontWeight: 900,
|
||
background: 'rgba(156,255,198,0.12)',
|
||
border: '1px solid rgba(156,255,198,0.18)',
|
||
backdropFilter: 'blur(12px)',
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Chip
|
||
size="small"
|
||
label="Вы не на сервере"
|
||
sx={{
|
||
height: '1.7vw',
|
||
borderRadius: '999px',
|
||
color: 'rgba(255,255,255,0.85)',
|
||
fontWeight: 900,
|
||
background: 'rgba(255,255,255,0.06)',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
backdropFilter: 'blur(12px)',
|
||
}}
|
||
/>
|
||
);
|
||
}, [statusLoading, isOnline, playerServer]);
|
||
|
||
return (
|
||
<Box
|
||
sx={{
|
||
mt: '10vh',
|
||
width: '100%',
|
||
height: '100%',
|
||
overflowY: 'auto',
|
||
boxSizing: 'border-box',
|
||
px: '5vw',
|
||
pb: '2vw',
|
||
}}
|
||
>
|
||
<CustomNotification
|
||
open={notifOpen}
|
||
message={notifMsg}
|
||
severity={notifSeverity}
|
||
position={notifPos}
|
||
onClose={() => setNotifOpen(false)}
|
||
autoHideDuration={2500}
|
||
/>
|
||
|
||
{/* EDIT PRICE */}
|
||
<Dialog
|
||
open={editPriceOpen}
|
||
onClose={() => setEditPriceOpen(false)}
|
||
fullWidth
|
||
maxWidth="xs"
|
||
PaperProps={{ sx: GLASS_PAPER_SX }}
|
||
>
|
||
<DialogTitle sx={DIALOG_TITLE_SX}>
|
||
Изменить цену
|
||
<IconButton onClick={() => setEditPriceOpen(false)} sx={CLOSE_BTN_SX}>
|
||
<CloseIcon fontSize="small" />
|
||
</IconButton>
|
||
</DialogTitle>
|
||
|
||
<DialogContent dividers sx={DIALOG_DIVIDERS_SX}>
|
||
<Typography sx={{ color: 'rgba(255,255,255,0.72)', fontWeight: 800, mb: 1 }}>
|
||
Укажи новую цену в монетах
|
||
</Typography>
|
||
|
||
<TextField
|
||
fullWidth
|
||
label="Новая цена"
|
||
value={editPriceValue}
|
||
onChange={(e) => setEditPriceValue(e.target.value)}
|
||
inputProps={{ min: 1 }}
|
||
sx={PRICE_FIELD_SX}
|
||
/>
|
||
|
||
{/* <TextField
|
||
fullWidth
|
||
label="Описание (необязательно)"
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
inputProps={{ min: 1 }}
|
||
sx={PRICE_FIELD_SX}
|
||
/> */}
|
||
|
||
{editItem && (
|
||
<Box sx={{ mt: 1.2, color: 'rgba(255,255,255,0.62)', fontWeight: 700 }}>
|
||
Текущая цена: <b style={{ color: 'rgba(255,255,255,0.9)' }}>{editItem.price}</b>
|
||
</Box>
|
||
)}
|
||
</DialogContent>
|
||
|
||
<DialogActions sx={{ p: '1.2vw' }}>
|
||
<Button
|
||
onClick={() => setEditPriceOpen(false)}
|
||
sx={{
|
||
color: 'rgba(255,255,255,0.85)',
|
||
fontFamily: 'Benzin-Bold',
|
||
transition: 'all 0.25s ease',
|
||
'&:hover': {
|
||
color: 'rgba(200, 200, 200, 1)',
|
||
background: 'transparent'
|
||
}
|
||
}}
|
||
>
|
||
Отмена
|
||
</Button>
|
||
|
||
<Button
|
||
onClick={handleSavePrice}
|
||
disableRipple
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
color: '#fff',
|
||
background: GRADIENT,
|
||
borderRadius: '999px',
|
||
px: '1.6vw',
|
||
py: '0.65vw',
|
||
boxShadow: '0 1.0vw 2.6vw rgba(0,0,0,0.45)',
|
||
'&:hover': { filter: 'brightness(1.05)' },
|
||
}}
|
||
>
|
||
Сохранить
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
|
||
{/* DELETE */}
|
||
<Dialog
|
||
open={removeOpen}
|
||
onClose={() => setRemoveOpen(false)}
|
||
fullWidth
|
||
maxWidth="xs"
|
||
PaperProps={{ sx: GLASS_PAPER_SX }}
|
||
>
|
||
<DialogTitle sx={DIALOG_TITLE_SX}>
|
||
Снять товар с продажи?
|
||
<IconButton onClick={() => setRemoveOpen(false)} sx={CLOSE_BTN_SX}>
|
||
<CloseIcon fontSize="small" />
|
||
</IconButton>
|
||
</DialogTitle>
|
||
|
||
<DialogContent dividers sx={DIALOG_DIVIDERS_SX}>
|
||
<Typography sx={{ color: 'rgba(255,255,255,0.78)', fontWeight: 800 }}>
|
||
Товар исчезнет с рынка и вернется вам в инвентарь!
|
||
</Typography>
|
||
|
||
{removeItem && (
|
||
<Box
|
||
sx={{
|
||
mt: 1.2,
|
||
p: '0.9vw',
|
||
borderRadius: '1.0vw',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
background: 'rgba(255,255,255,0.05)',
|
||
}}
|
||
>
|
||
<Typography sx={{ fontFamily: 'Benzin-Bold', color: 'rgba(255,255,255,0.92)' }}>
|
||
{removeItem.display_name || removeItem.material}
|
||
</Typography>
|
||
<Typography sx={{ color: 'rgba(255,255,255,0.70)', fontWeight: 700, mt: 0.3 }}>
|
||
Цена: {removeItem.price} монет
|
||
</Typography>
|
||
</Box>
|
||
)}
|
||
</DialogContent>
|
||
|
||
<DialogActions sx={{ p: '1.2vw' }}>
|
||
<Button
|
||
onClick={() => setRemoveOpen(false)}
|
||
sx={{
|
||
color: 'rgba(255,255,255,0.85)',
|
||
fontFamily: 'Benzin-Bold',
|
||
transition: 'all 0.25s ease',
|
||
'&:hover': {
|
||
color: 'rgba(200, 200, 200, 1)',
|
||
background: 'transparent'
|
||
}
|
||
}}
|
||
>
|
||
Отмена
|
||
</Button>
|
||
|
||
<Button
|
||
onClick={handleRemoveItem}
|
||
disableRipple
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
color: '#fff',
|
||
background: 'rgba(255,70,70,0.20)',
|
||
border: '1px solid rgba(255,70,70,0.26)',
|
||
borderRadius: '999px',
|
||
px: '1.6vw',
|
||
py: '0.65vw',
|
||
'&:hover': { background: 'rgba(255,70,70,0.26)' },
|
||
}}
|
||
>
|
||
Снять
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
|
||
{/* FULL INFO ITEM */}
|
||
<Dialog
|
||
open={metaDialogOpen}
|
||
onClose={closeMetaDialog}
|
||
fullWidth
|
||
maxWidth="sm"
|
||
PaperProps={{ sx: GLASS_PAPER_SX }}
|
||
>
|
||
<DialogTitle sx={DIALOG_TITLE_SX}>
|
||
Информация о предмете
|
||
<IconButton onClick={closeMetaDialog} sx={CLOSE_BTN_SX}>
|
||
<CloseIcon fontSize="small" />
|
||
</IconButton>
|
||
</DialogTitle>
|
||
|
||
<DialogContent dividers sx={DIALOG_DIVIDERS_SX}>
|
||
{metaItem && (() => {
|
||
const meta = getItemMeta(metaItem);
|
||
|
||
const enchants = meta ? formatEnchants(meta.enchants) : [];
|
||
|
||
const filteredMeta = meta
|
||
? Object.entries(meta).filter(([key]) =>
|
||
key !== 'enchants' &&
|
||
metaKeyMatchesSearch(key, metaSearch)
|
||
)
|
||
: [];
|
||
|
||
return (
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1vw' }}>
|
||
|
||
{/* IMAGE + TITLE */}
|
||
<Box sx={{ display: 'flex', gap: '1vw', alignItems: 'center' }}>
|
||
<Box
|
||
sx={{
|
||
width: 80,
|
||
height: 80,
|
||
borderRadius: '1vw',
|
||
border: '1px solid rgba(255,255,255,0.12)',
|
||
background: 'rgba(0,0,0,0.35)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<img
|
||
src={`https://cdn.minecraft.popa-popa.ru/textures/${metaItem.material.toLowerCase()}.png`}
|
||
alt={metaItem.material}
|
||
style={{ width: '70%', imageRendering: 'pixelated' }}
|
||
/>
|
||
</Box>
|
||
|
||
<Box>
|
||
<Typography sx={{ fontFamily: 'Benzin-Bold', fontSize: '1.2rem' }}>
|
||
{metaItem.display_name || metaItem.material}
|
||
</Typography>
|
||
|
||
<Typography sx={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
|
||
Количество: {metaItem.amount} · Цена: {metaItem.price}
|
||
</Typography>
|
||
</Box>
|
||
</Box>
|
||
|
||
<Divider sx={{mt: '0.7vw'}} />
|
||
|
||
{/* DESCRIPTION (optional) */}
|
||
{metaItem.description && (
|
||
<Typography>
|
||
Описание
|
||
<Box
|
||
sx={{
|
||
mt: 0.6,
|
||
p: '0.8vw',
|
||
borderRadius: '0.8vw',
|
||
background: 'rgba(255,255,255,0.06)',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
}}
|
||
>
|
||
<Typography sx={{ color: 'rgba(255,255,255,0.85)', fontSize: '0.9rem' }}>
|
||
{metaItem.description}
|
||
</Typography>
|
||
</Box>
|
||
|
||
<Divider sx={{mt: '2vw'}} />
|
||
</Typography>
|
||
)}
|
||
|
||
{/* SEARCH */}
|
||
{meta && Object.keys(meta).length > 0 && (
|
||
<Box>
|
||
<TextField
|
||
placeholder="Поиск по метаданным..."
|
||
value={metaSearch}
|
||
onChange={(e) => setMetaSearch(e.target.value)}
|
||
fullWidth
|
||
sx={PRICE_FIELD_SX}
|
||
/>
|
||
<Divider sx={{mt: '2vw'}} />
|
||
</Box>
|
||
)}
|
||
|
||
{/* ENCHANTS */}
|
||
{enchants && (
|
||
<>
|
||
{enchants.length > 0 &&
|
||
<Typography sx={{ fontFamily: 'Benzin-Bold', fontSize: '1rem' }}>
|
||
Зачарования
|
||
</Typography>
|
||
}
|
||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '0.6vw' }}>
|
||
{enchants.map((e) => (
|
||
<Chip
|
||
key={e.label}
|
||
label={`${e.label} ${e.level}`}
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
background: 'rgba(156,255,198,0.15)',
|
||
border: '1px solid rgba(156,255,198,0.25)',
|
||
color: 'rgba(156,255,198,0.95)',
|
||
}}
|
||
/>
|
||
))}
|
||
</Box>
|
||
</>
|
||
)}
|
||
|
||
{/* OTHER META */}
|
||
{filteredMeta.length > 0 && (
|
||
<>
|
||
<Typography sx={{ fontFamily: 'Benzin-Bold', fontSize: '1rem' }}>
|
||
Свойства
|
||
</Typography>
|
||
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '0.4vw' }}>
|
||
{filteredMeta.map(([key, value]) => (
|
||
<Box
|
||
key={key}
|
||
sx={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
gap: '1vw',
|
||
fontSize: '0.85rem',
|
||
color: 'rgba(255,255,255,0.85)',
|
||
background: 'rgba(255,255,255,0.04)',
|
||
borderRadius: '0.6vw',
|
||
px: '0.8vw',
|
||
py: '0.4vw',
|
||
}}
|
||
>
|
||
<span style={{ opacity: 0.7 }}>{translateMetaKey(key)}</span>
|
||
<span style={{ fontWeight: 600 }}>{formatMetaValue(value)}</span>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</>
|
||
)}
|
||
</Box>
|
||
);
|
||
})()}
|
||
</DialogContent>
|
||
|
||
<DialogActions sx={{ p: '1.2vw' }}>
|
||
<Button
|
||
onClick={closeMetaDialog}
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
color: '#fff',
|
||
borderRadius: '999px',
|
||
px: '1.6vw',
|
||
py: '0.6vw',
|
||
background: GRADIENT,
|
||
}}
|
||
>
|
||
Закрыть
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
|
||
{/* HEADER (glass) */}
|
||
<Box
|
||
sx={{
|
||
borderRadius: '1.2vw',
|
||
overflow: 'hidden',
|
||
background: GLASS_BG,
|
||
border: GLASS_BORDER,
|
||
boxShadow: GLASS_SHADOW,
|
||
backdropFilter: 'blur(14px)',
|
||
}}
|
||
>
|
||
<Box
|
||
sx={{
|
||
px: '2vw',
|
||
py: '1.4vw',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: '1vw',
|
||
flexWrap: 'wrap',
|
||
}}
|
||
>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: '1vw', flexWrap: 'wrap' }}>
|
||
<Typography
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
fontSize: '1.9vw',
|
||
lineHeight: 1.1,
|
||
color: '#fff',
|
||
textTransform: 'uppercase',
|
||
}}
|
||
>
|
||
Рынок сервера
|
||
</Typography>
|
||
|
||
{/* сервер селект (как в Settings/Shop) */}
|
||
<FormControl
|
||
size="small"
|
||
sx={{
|
||
// textTransform: 'uppercase',
|
||
// чтобы по ширине вел себя как бейдж
|
||
// minWidth: '18vw',
|
||
// maxWidth: '28vw',
|
||
|
||
'& .MuiInputLabel-root': { display: 'none' },
|
||
|
||
'& .MuiOutlinedInput-root': {
|
||
borderRadius: '3vw',
|
||
position: 'relative',
|
||
px: '1.2vw',
|
||
py: '0.5vw',
|
||
|
||
color: 'rgba(255,255,255,0.95)',
|
||
background:
|
||
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.20), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.16), transparent 55%), rgba(10,10,20,0.92)',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
boxShadow: '0 1.4vw 3.8vw rgba(0,0,0,0.55)',
|
||
backdropFilter: 'blur(14px)',
|
||
overflow: 'hidden',
|
||
|
||
transition: 'transform 0.18s ease, filter 0.18s ease',
|
||
'&:hover': {
|
||
transform: 'scale(1.02)',
|
||
// filter: 'brightness(1.03)',
|
||
},
|
||
|
||
'& .MuiOutlinedInput-notchedOutline': {
|
||
border: 'none',
|
||
},
|
||
|
||
// нижняя градиентная полоска как у username
|
||
'&:after': {
|
||
content: '""',
|
||
position: 'absolute',
|
||
left: '0%',
|
||
right: '0%',
|
||
bottom: 0,
|
||
height: '0.15vw',
|
||
borderRadius: '999px',
|
||
background: GRADIENT,
|
||
opacity: 0.9,
|
||
pointerEvents: 'none',
|
||
width: '100%',
|
||
},
|
||
},
|
||
|
||
'& .MuiSelect-select': {
|
||
// стиль текста как у плашки username
|
||
fontFamily: 'Benzin-Bold',
|
||
fontSize: '1.9vw',
|
||
lineHeight: 1.1,
|
||
textAlign: 'center',
|
||
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
|
||
// убираем “инпутный” паддинг
|
||
padding: 0,
|
||
minHeight: 'unset',
|
||
|
||
// чтобы длинные названия не ломали
|
||
whiteSpace: 'nowrap',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
},
|
||
|
||
// '& .MuiSelect-icon': {
|
||
// color: 'rgba(255,255,255,0.80)',
|
||
// right: '1.0vw',
|
||
// fontSize: '2.2vw',
|
||
// },
|
||
}}
|
||
>
|
||
<Select
|
||
value={selectedServer?.id ?? ''}
|
||
onChange={(e) => {
|
||
const next = servers.find((s) => s.id === e.target.value) ?? null;
|
||
setSelectedServer(next);
|
||
}}
|
||
displayEmpty
|
||
renderValue={(value) => {
|
||
const srv = servers.find((s) => s.id === value);
|
||
return translateServer(srv?.name ?? 'Выберите сервер');
|
||
}}
|
||
MenuProps={{
|
||
PaperProps: {
|
||
sx: {
|
||
mt: 1,
|
||
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',
|
||
fontSize: '1.1vw',
|
||
},
|
||
'& .MuiMenuItem-root.Mui-selected': {
|
||
backgroundColor: 'rgba(242,113,33,0.18)',
|
||
},
|
||
'& .MuiMenuItem-root:hover': {
|
||
backgroundColor: 'rgba(233,64,205,0.14)',
|
||
},
|
||
},
|
||
},
|
||
}}
|
||
>
|
||
{servers.map((s) => (
|
||
<MenuItem key={s.id} value={s.id}>
|
||
{translateServer(s.name)}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
</FormControl>
|
||
|
||
{statusChip}
|
||
</Box>
|
||
|
||
<Button
|
||
disableRipple
|
||
onClick={handleRefreshAll}
|
||
sx={{
|
||
borderRadius: '999px',
|
||
px: '1.2vw',
|
||
py: '0.6vw',
|
||
fontFamily: 'Benzin-Bold',
|
||
color: '#fff',
|
||
background: 'rgba(255,255,255,0.08)',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
'&:hover': { background: 'rgba(255,255,255,0.12)' },
|
||
}}
|
||
>
|
||
Обновить
|
||
</Button>
|
||
</Box>
|
||
|
||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.10)' }} />
|
||
|
||
{/* TABS */}
|
||
<Box sx={{ px: '1.4vw', pt: '0.6vw', pb: '0.4vw' }}>
|
||
<Tabs
|
||
value={tabValue}
|
||
onChange={handleTabChange}
|
||
aria-label="marketplace tabs"
|
||
disableFocusRipple
|
||
disableRipple
|
||
sx={{
|
||
minHeight: '3vw',
|
||
'& .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>
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* CONTENT */}
|
||
<TabPanel value={tabValue} index={0}>
|
||
{marketLoading ? (
|
||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: '6vh' }}>
|
||
<FullScreenLoader fullScreen={false} message="Загрузка товаров..." />
|
||
</Box>
|
||
) : !marketItems || marketItems.items.length === 0 ? (
|
||
<Box
|
||
sx={{
|
||
mt: '2vw',
|
||
borderRadius: '1.2vw',
|
||
background: GLASS_BG,
|
||
border: GLASS_BORDER,
|
||
boxShadow: GLASS_SHADOW,
|
||
p: '2vw',
|
||
textAlign: 'center',
|
||
}}
|
||
>
|
||
<Typography sx={{ color: 'rgba(255,255,255,0.85)', fontWeight: 800, fontSize: '1.2vw' }}>
|
||
На данный момент на рынке нет предметов.
|
||
</Typography>
|
||
|
||
<Button
|
||
disableRipple
|
||
onClick={() => selectedServer && loadMarketItems(selectedServer.ip, 1)}
|
||
sx={{
|
||
mt: '1.2vw',
|
||
borderRadius: '2.5vw',
|
||
px: '2.4vw',
|
||
py: '0.9vw',
|
||
fontFamily: 'Benzin-Bold',
|
||
color: '#fff',
|
||
background: GRADIENT,
|
||
boxShadow: '0 1.0vw 2.8vw rgba(0,0,0,0.45)',
|
||
'&:hover': { filter: 'brightness(1.05)' },
|
||
}}
|
||
>
|
||
Обновить
|
||
</Button>
|
||
</Box>
|
||
) : (
|
||
<>
|
||
<Grid container spacing={2} sx={{ mt: '0.6vw' }}>
|
||
{marketItems.items.map((item) => {
|
||
const title =
|
||
item.display_name ||
|
||
item.material
|
||
.replace(/_/g, ' ')
|
||
.toLowerCase()
|
||
.replace(/\b\w/g, (l) => l.toUpperCase());
|
||
|
||
return (
|
||
<Grid item xs={12} sm={6} md={4} lg={3} key={item.id}>
|
||
<Card
|
||
sx={{
|
||
position: 'relative',
|
||
width: '100%',
|
||
height: '100%',
|
||
display: 'flex',
|
||
ml: '25%',
|
||
flexDirection: 'column',
|
||
background: 'rgba(20,20,20,0.78)',
|
||
borderRadius: '2.0vw',
|
||
border: '1px solid rgba(255,255,255,0.08)',
|
||
boxShadow: '0 10px 40px rgba(0,0,0,0.55)',
|
||
overflow: 'hidden',
|
||
transition: 'transform 0.25s ease, box-shadow 0.25s ease',
|
||
'&:hover': {
|
||
transform: 'translateY(-2px)',
|
||
boxShadow: '0 18px 50px rgba(0,0,0,0.65)',
|
||
},
|
||
}}
|
||
>
|
||
{/* top glow */}
|
||
<Box
|
||
sx={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
pointerEvents: 'none',
|
||
background:
|
||
'radial-gradient(circle at top, rgba(242,113,33,0.18), transparent 60%)',
|
||
}}
|
||
/>
|
||
|
||
<Box sx={{ position: 'relative', p: '1.0vw', pb: 0 }}>
|
||
<Box
|
||
sx={{
|
||
position: 'relative', // 👈 важно
|
||
borderRadius: '1.4vw',
|
||
overflow: 'hidden',
|
||
border: '1px solid rgba(255,255,255,0.12)',
|
||
background:
|
||
'linear-gradient(135deg, rgba(40,40,40,0.9), rgba(15,15,15,0.9))',
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
}}
|
||
>
|
||
{/* INFO BUTTON */}
|
||
{hasItemDetails(item) && (
|
||
<IconButton
|
||
onClick={() => openMetaDialog(item)}
|
||
size="small"
|
||
sx={{
|
||
position: 'absolute',
|
||
top: '0.5vw',
|
||
right: '0.5vw',
|
||
width: '1.8vw',
|
||
height: '1.8vw',
|
||
borderRadius: '50%',
|
||
zIndex: 2,
|
||
|
||
color: 'rgba(255,255,255,0.85)',
|
||
background: 'rgba(0,0,0,0.45)',
|
||
border: '1px solid rgba(255,255,255,0.18)',
|
||
backdropFilter: 'blur(8px)',
|
||
|
||
'&:hover': {
|
||
background: 'rgba(255,255,255,0.15)',
|
||
transform: 'scale(1.05)',
|
||
},
|
||
transition: 'all 0.2s ease',
|
||
}}
|
||
>
|
||
i
|
||
</IconButton>
|
||
)}
|
||
|
||
<CardMedia
|
||
component="img"
|
||
sx={{
|
||
width: '100%',
|
||
height: '10vw',
|
||
minHeight: 140,
|
||
objectFit: 'contain',
|
||
p: '1.0vw',
|
||
imageRendering: 'pixelated',
|
||
}}
|
||
image={`https://cdn.minecraft.popa-popa.ru/textures/${item.material.toLowerCase()}.png`}
|
||
alt={item.material}
|
||
/>
|
||
</Box>
|
||
</Box>
|
||
|
||
<CardContent
|
||
sx={{
|
||
maxWidth: '18vw',
|
||
position: 'relative',
|
||
flexGrow: 1,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'space-between',
|
||
pt: '1.0vw',
|
||
}}
|
||
>
|
||
<Box sx={{ minWidth: 0 }}>
|
||
<Typography
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
fontSize: '1.05rem',
|
||
mb: 0.8,
|
||
backgroundImage: GRADIENT,
|
||
WebkitBackgroundClip: 'text',
|
||
WebkitTextFillColor: 'transparent',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
title={title}
|
||
>
|
||
{title}
|
||
</Typography>
|
||
|
||
<Typography sx={{ color: 'rgba(255,255,255,0.75)', fontWeight: 700, fontSize: '0.9rem' }}>
|
||
Количество: {item.amount}
|
||
</Typography>
|
||
<Typography sx={{ color: 'rgba(255,255,255,0.75)', fontWeight: 700, fontSize: '0.9rem' }}>
|
||
Цена: {item.price} монет
|
||
</Typography>
|
||
|
||
<Typography sx={{ color: 'rgba(255,255,255,0.60)', fontWeight: 700, fontSize: '0.85rem', mt: 0.3 }}>
|
||
Продавец: {item.seller_name}
|
||
</Typography>
|
||
</Box>
|
||
|
||
<Button
|
||
variant="contained"
|
||
fullWidth
|
||
disableRipple
|
||
disabled={!canBuyOnSelectedServer}
|
||
onClick={() => handleBuyItem(item.id)}
|
||
sx={{
|
||
mt: '1.1vw',
|
||
borderRadius: '2.5vw',
|
||
py: '0.9vw',
|
||
fontFamily: 'Benzin-Bold',
|
||
background: canBuyOnSelectedServer ? GRADIENT : 'rgba(255,255,255,0.10)',
|
||
color: '#fff',
|
||
transition: 'transform 0.2s ease, filter 0.2s ease',
|
||
'&:hover': {
|
||
transform: canBuyOnSelectedServer ? 'scale(1.01)' : 'none',
|
||
filter: canBuyOnSelectedServer ? 'brightness(1.03)' : 'none',
|
||
},
|
||
'&.Mui-disabled': {
|
||
color: 'rgba(255,255,255,0.45)',
|
||
},
|
||
}}
|
||
>
|
||
Купить
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
);
|
||
})}
|
||
</Grid>
|
||
|
||
{totalPages > 1 && (
|
||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: '2vw' }}>
|
||
<Pagination
|
||
count={totalPages}
|
||
page={page}
|
||
onChange={handlePageChange}
|
||
sx={{
|
||
'& .MuiPaginationItem-root': {
|
||
color: 'rgba(255,255,255,0.85)',
|
||
fontFamily: 'Benzin-Bold',
|
||
borderRadius: '999px',
|
||
border: '1px solid rgba(255,255,255,0.12)',
|
||
background: 'rgba(255,255,255,0.05)',
|
||
'&:hover': { background: 'rgba(255,255,255,0.08)' },
|
||
},
|
||
'& .MuiPaginationItem-root.Mui-selected': {
|
||
background: GRADIENT,
|
||
border: '1px solid rgba(255,255,255,0.14)',
|
||
color: '#fff',
|
||
},
|
||
}}
|
||
/>
|
||
</Box>
|
||
)}
|
||
</>
|
||
)}
|
||
</TabPanel>
|
||
|
||
{/* "Мой инвентарь" */}
|
||
<TabPanel value={tabValue} index={1}>
|
||
<Box
|
||
sx={{
|
||
borderRadius: '1.2vw',
|
||
background: GLASS_BG,
|
||
border: GLASS_BORDER,
|
||
boxShadow: GLASS_SHADOW,
|
||
p: '1.6vw',
|
||
}}
|
||
>
|
||
{playerServer && username ? (
|
||
<PlayerInventory
|
||
ref={inventoryRef}
|
||
username={username}
|
||
serverIp={playerServer.ip}
|
||
onSellSuccess={() => {
|
||
if (selectedServer) loadMarketItems(selectedServer.ip, 1);
|
||
showNotification('Предмет успешно выставлен на продажу!', 'success');
|
||
}}
|
||
/>
|
||
) : (
|
||
<Typography sx={{ color: 'rgba(255,255,255,0.75)', textAlign: 'center', my: 2 }}>
|
||
Не удалось загрузить инвентарь.
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
</TabPanel>
|
||
|
||
<TabPanel value={tabValue} index={2}>
|
||
{myLoading ? (
|
||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: '6vh' }}>
|
||
<FullScreenLoader fullScreen={false} message="Загрузка ваших товаров..." />
|
||
</Box>
|
||
) : !myItems || myItems.items.length === 0 ? (
|
||
<Box
|
||
sx={{
|
||
mt: '2vw',
|
||
borderRadius: '1.2vw',
|
||
background: GLASS_BG,
|
||
border: GLASS_BORDER,
|
||
boxShadow: GLASS_SHADOW,
|
||
p: '2vw',
|
||
textAlign: 'center',
|
||
}}
|
||
>
|
||
<Typography sx={{ color: 'rgba(255,255,255,0.85)', fontWeight: 800, fontSize: '1.2vw' }}>
|
||
У вас нет выставленных товаров.
|
||
</Typography>
|
||
|
||
<Button
|
||
disableRipple
|
||
onClick={() => selectedServer && loadMyItems(selectedServer.ip, 1)}
|
||
sx={{
|
||
mt: '1.2vw',
|
||
borderRadius: '2.5vw',
|
||
px: '2.4vw',
|
||
py: '0.9vw',
|
||
fontFamily: 'Benzin-Bold',
|
||
color: '#fff',
|
||
background: GRADIENT,
|
||
boxShadow: '0 1.0vw 2.8vw rgba(0,0,0,0.45)',
|
||
'&:hover': { filter: 'brightness(1.05)' },
|
||
}}
|
||
>
|
||
Обновить
|
||
</Button>
|
||
</Box>
|
||
) : (
|
||
<>
|
||
<Grid container spacing={2} sx={{ mt: '0.6vw' }}>
|
||
{myItems.items.map((item) => {
|
||
const title =
|
||
item.display_name ||
|
||
item.material
|
||
.replace(/_/g, ' ')
|
||
.toLowerCase()
|
||
.replace(/\b\w/g, (l) => l.toUpperCase());
|
||
|
||
return (
|
||
<Grid item xs={12} sm={6} md={4} lg={3} key={item.id}>
|
||
<Card
|
||
sx={{
|
||
position: 'relative',
|
||
width: '100%',
|
||
height: '100%',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
background: 'rgba(20,20,20,0.78)',
|
||
borderRadius: '2.0vw',
|
||
border: '1px solid rgba(255,255,255,0.08)',
|
||
boxShadow: '0 10px 40px rgba(0,0,0,0.55)',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
<Box
|
||
sx={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
pointerEvents: 'none',
|
||
background:
|
||
'radial-gradient(circle at top, rgba(242,113,33,0.18), transparent 60%)',
|
||
}}
|
||
/>
|
||
|
||
<Box sx={{ position: 'relative', p: '1.0vw', pb: 0 }}>
|
||
<Box
|
||
sx={{
|
||
borderRadius: '1.4vw',
|
||
overflow: 'hidden',
|
||
border: '1px solid rgba(255,255,255,0.12)',
|
||
background:
|
||
'linear-gradient(135deg, rgba(40,40,40,0.9), rgba(15,15,15,0.9))',
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
}}
|
||
>
|
||
<CardMedia
|
||
component="img"
|
||
sx={{
|
||
width: '100%',
|
||
height: '10vw',
|
||
minHeight: 140,
|
||
objectFit: 'contain',
|
||
p: '1.0vw',
|
||
imageRendering: 'pixelated',
|
||
}}
|
||
image={`https://cdn.minecraft.popa-popa.ru/textures/${item.material.toLowerCase()}.png`}
|
||
alt={item.material}
|
||
/>
|
||
</Box>
|
||
</Box>
|
||
|
||
<CardContent
|
||
sx={{
|
||
maxWidth: '18vw',
|
||
position: 'relative',
|
||
flexGrow: 1,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'space-between',
|
||
pt: '1.0vw',
|
||
}}
|
||
>
|
||
<Box sx={{ minWidth: 0 }}>
|
||
<Typography
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
fontSize: '1.05rem',
|
||
mb: 0.8,
|
||
backgroundImage: GRADIENT,
|
||
WebkitBackgroundClip: 'text',
|
||
WebkitTextFillColor: 'transparent',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
title={title}
|
||
>
|
||
{title}
|
||
</Typography>
|
||
|
||
<Typography sx={{ color: 'rgba(255,255,255,0.75)', fontWeight: 700, fontSize: '0.9rem' }}>
|
||
Количество: {item.amount}
|
||
</Typography>
|
||
<Typography sx={{ color: 'rgba(255,255,255,0.75)', fontWeight: 700, fontSize: '0.9rem' }}>
|
||
Цена: {item.price} монет
|
||
</Typography>
|
||
|
||
<Typography sx={{ color: 'rgba(255,255,255,0.60)', fontWeight: 700, fontSize: '0.85rem', mt: 0.3 }}>
|
||
Сервер: {translateServer(selectedServer?.name ?? '')}
|
||
</Typography>
|
||
|
||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '0.6vw', mt: '1.1vw' }}>
|
||
<Button
|
||
fullWidth
|
||
disableRipple
|
||
onClick={() => openEditPrice(item)}
|
||
sx={{
|
||
borderRadius: '2.5vw',
|
||
fontSize: '1vw',
|
||
py: '0.75vw',
|
||
fontFamily: 'Benzin-Bold',
|
||
background: 'rgba(255,255,255,0.08)',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
color: '#fff',
|
||
'&:hover': { background: 'rgba(255,255,255,0.12)' },
|
||
}}
|
||
>
|
||
Изменить
|
||
</Button>
|
||
|
||
<Button
|
||
fullWidth
|
||
disableRipple
|
||
onClick={() => openRemove(item)}
|
||
sx={{
|
||
borderRadius: '2.5vw',
|
||
fontSize: '1vw',
|
||
py: '0.75vw',
|
||
fontFamily: 'Benzin-Bold',
|
||
background: 'rgba(255,70,70,0.14)',
|
||
border: '1px solid rgba(255,70,70,0.22)',
|
||
color: '#fff',
|
||
'&:hover': { background: 'rgba(255,70,70,0.18)' },
|
||
}}
|
||
>
|
||
Снять
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
);
|
||
})}
|
||
</Grid>
|
||
|
||
{myTotalPages > 1 && (
|
||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: '2vw' }}>
|
||
<Pagination
|
||
count={myTotalPages}
|
||
page={myPage}
|
||
onChange={(_e, p) => selectedServer && loadMyItems(selectedServer.ip, p)}
|
||
sx={{
|
||
'& .MuiPaginationItem-root': {
|
||
color: 'rgba(255,255,255,0.85)',
|
||
fontFamily: 'Benzin-Bold',
|
||
borderRadius: '999px',
|
||
border: '1px solid rgba(255,255,255,0.12)',
|
||
background: 'rgba(255,255,255,0.05)',
|
||
'&:hover': { background: 'rgba(255,255,255,0.08)' },
|
||
},
|
||
'& .MuiPaginationItem-root.Mui-selected': {
|
||
background: GRADIENT,
|
||
border: '1px solid rgba(255,255,255,0.14)',
|
||
color: '#fff',
|
||
},
|
||
}}
|
||
/>
|
||
</Box>
|
||
)}
|
||
</>
|
||
)}
|
||
</TabPanel>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
function ItemMetaTooltip({ meta }: { meta: Record<string, any> }) {
|
||
return (
|
||
<Box sx={{ minWidth: 220 }}>
|
||
<Typography
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
fontSize: '0.85rem',
|
||
mb: 0.6,
|
||
color: '#fff',
|
||
}}
|
||
>
|
||
Информация о предмете
|
||
</Typography>
|
||
|
||
{Object.entries(meta).map(([key, value]) => (
|
||
<Box
|
||
key={key}
|
||
sx={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
gap: '0.8vw',
|
||
fontSize: '0.75rem',
|
||
color: 'rgba(255,255,255,0.75)',
|
||
}}
|
||
>
|
||
<span>{key}</span>
|
||
<span style={{ opacity: 0.9 }}>
|
||
{typeof value === 'object'
|
||
? JSON.stringify(value)
|
||
: String(value)}
|
||
</span>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
);
|
||
}
|
||
|