Files
popa-launcher/src/renderer/pages/Marketplace.tsx
2025-12-29 22:31:20 +05:00

1818 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
);
}