// 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 (
{value === index && {children}}
);
}
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(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(list: T[], id: string) {
return list.filter((x) => x.id !== id);
}
export default function Marketplace() {
const [marketLoading, setMarketLoading] = useState(false);
const [isOnline, setIsOnline] = useState(false);
const [username, setUsername] = useState('');
const [playerServer, setPlayerServer] = useState(null);
const [marketItems, setMarketItems] = useState(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [tabValue, setTabValue] = useState(0);
// notifications
const [notifOpen, setNotifOpen] = useState(false);
const [notifMsg, setNotifMsg] = useState('');
const [notifSeverity, setNotifSeverity] = useState<
'success' | 'info' | 'warning' | 'error'
>('info');
const [notifPos, setNotifPos] = useState({
vertical: 'bottom',
horizontal: 'center',
});
// servers
const [servers, setServers] = useState([]);
const [selectedServer, setSelectedServer] = useState(null);
// “тихий” лоадер статуса
const [statusLoading, setStatusLoading] = useState(false);
const [myItems, setMyItems] = useState(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(null);
const [editPriceValue, setEditPriceValue] = useState('');
const [description, setDescription] = useState('');
const [removeOpen, setRemoveOpen] = useState(false);
const [removeItem, setRemoveItem] = useState(null);
const inventoryRef = useRef(null);
const [metaDialogOpen, setMetaDialogOpen] = useState(false);
const [metaItem, setMetaItem] = useState(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, 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(
<>
Вы сейчас на сервере {translateServer(server.name)}.
Для покупки переключитесь на него в списке серверов или зайдите на{' '}
{translateServer(selectedServer?.name ?? '')}.
>,
'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 = {
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 = {
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 = {
fontFamily: 'Benzin-Bold',
pr: 6,
position: 'relative',
};
const CLOSE_BTN_SX: SxProps = {
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 = {
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) => {
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 (
);
}
if (isOnline && playerServer) {
return (
);
}
return (
);
}, [statusLoading, isOnline, playerServer]);
return (
setNotifOpen(false)}
autoHideDuration={2500}
/>
{/* EDIT PRICE */}
{/* DELETE */}
{/* FULL INFO ITEM */}
{/* HEADER (glass) */}
Рынок сервера
{/* сервер селект (как в Settings/Shop) */}
{statusChip}
{/* TABS */}
{['Товары', 'Мой инвентарь', 'Мои товары'].map((label) => (
))}
{/* CONTENT */}
{marketLoading ? (
) : !marketItems || marketItems.items.length === 0 ? (
На данный момент на рынке нет предметов.
) : (
<>
{marketItems.items.map((item) => {
const title =
item.display_name ||
item.material
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (l) => l.toUpperCase());
return (
{/* top glow */}
{/* INFO BUTTON */}
{hasItemDetails(item) && (
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
)}
{title}
Количество: {item.amount}
Цена: {item.price} монет
Продавец: {item.seller_name}
);
})}
{totalPages > 1 && (
)}
>
)}
{/* "Мой инвентарь" */}
{playerServer && username ? (
{
if (selectedServer) loadMarketItems(selectedServer.ip, 1);
showNotification('Предмет успешно выставлен на продажу!', 'success');
}}
/>
) : (
Не удалось загрузить инвентарь.
)}
{myLoading ? (
) : !myItems || myItems.items.length === 0 ? (
У вас нет выставленных товаров.
) : (
<>
{myItems.items.map((item) => {
const title =
item.display_name ||
item.material
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (l) => l.toUpperCase());
return (
{title}
Количество: {item.amount}
Цена: {item.price} монет
Сервер: {translateServer(selectedServer?.name ?? '')}
);
})}
{myTotalPages > 1 && (
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',
},
}}
/>
)}
>
)}
);
}
function ItemMetaTooltip({ meta }: { meta: Record }) {
return (
Информация о предмете
{Object.entries(meta).map(([key, value]) => (
{key}
{typeof value === 'object'
? JSON.stringify(value)
: String(value)}
))}
);
}