// 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 ( ); } 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 */} setEditPriceOpen(false)} fullWidth maxWidth="xs" PaperProps={{ sx: GLASS_PAPER_SX }} > Изменить цену setEditPriceOpen(false)} sx={CLOSE_BTN_SX}> Укажи новую цену в монетах setEditPriceValue(e.target.value)} inputProps={{ min: 1 }} sx={PRICE_FIELD_SX} /> {/* setDescription(e.target.value)} inputProps={{ min: 1 }} sx={PRICE_FIELD_SX} /> */} {editItem && ( Текущая цена: {editItem.price} )} {/* DELETE */} setRemoveOpen(false)} fullWidth maxWidth="xs" PaperProps={{ sx: GLASS_PAPER_SX }} > Снять товар с продажи? setRemoveOpen(false)} sx={CLOSE_BTN_SX}> Товар исчезнет с рынка и вернется вам в инвентарь! {removeItem && ( {removeItem.display_name || removeItem.material} Цена: {removeItem.price} монет )} {/* FULL INFO ITEM */} Информация о предмете {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 ( {/* IMAGE + TITLE */} {metaItem.material} {metaItem.display_name || metaItem.material} Количество: {metaItem.amount} · Цена: {metaItem.price} {/* DESCRIPTION (optional) */} {metaItem.description && ( Описание {metaItem.description} )} {/* SEARCH */} {meta && Object.keys(meta).length > 0 && ( setMetaSearch(e.target.value)} fullWidth sx={PRICE_FIELD_SX} /> )} {/* ENCHANTS */} {enchants && ( <> {enchants.length > 0 && Зачарования } {enchants.map((e) => ( ))} )} {/* OTHER META */} {filteredMeta.length > 0 && ( <> Свойства {filteredMeta.map(([key, value]) => ( {translateMetaKey(key)} {formatMetaValue(value)} ))} )} ); })()} {/* 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)} ))} ); }