578 lines
19 KiB
TypeScript
578 lines
19 KiB
TypeScript
import {
|
||
Box,
|
||
Typography,
|
||
Button,
|
||
Grid,
|
||
Card,
|
||
CardMedia,
|
||
CardContent,
|
||
Snackbar,
|
||
Alert,
|
||
Dialog,
|
||
DialogContent,
|
||
} from '@mui/material';
|
||
import CapeCard from '../components/CapeCard';
|
||
import {
|
||
Cape,
|
||
fetchCapes,
|
||
fetchCapesStore,
|
||
purchaseCape,
|
||
StoreCape,
|
||
Case,
|
||
CaseItem,
|
||
fetchCases,
|
||
fetchCase,
|
||
openCase,
|
||
Server,
|
||
} from '../api';
|
||
import { useEffect, useState } from 'react';
|
||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||
import { getPlayerServer } from '../utils/playerOnlineCheck';
|
||
import CaseRoulette from '../components/CaseRoulette';
|
||
import CoinsDisplay from '../components/CoinsDisplay';
|
||
|
||
function getRarityByWeight(
|
||
weight?: number,
|
||
): 'common' | 'rare' | 'epic' | 'legendary' {
|
||
if (weight === undefined || weight === null) return 'common';
|
||
|
||
if (weight <= 5) return 'legendary';
|
||
if (weight <= 20) return 'epic';
|
||
if (weight <= 50) return 'rare';
|
||
return 'common';
|
||
}
|
||
|
||
function getRarityColor(weight?: number): string {
|
||
const rarity = getRarityByWeight(weight);
|
||
switch (rarity) {
|
||
case 'legendary':
|
||
return 'rgba(255, 215, 0, 1)'; // золотой
|
||
case 'epic':
|
||
return 'rgba(186, 85, 211, 1)'; // фиолетовый
|
||
case 'rare':
|
||
return 'rgba(65, 105, 225, 1)'; // синий
|
||
case 'common':
|
||
default:
|
||
return 'rgba(255, 255, 255, 0.25)'; // сероватый
|
||
}
|
||
}
|
||
|
||
export default function Shop() {
|
||
const [storeCapes, setStoreCapes] = useState<StoreCape[]>([]);
|
||
const [userCapes, setUserCapes] = useState<Cape[]>([]);
|
||
const [username, setUsername] = useState<string>('');
|
||
const [uuid, setUuid] = useState<string>('');
|
||
const [loading, setLoading] = useState<boolean>(false);
|
||
|
||
// Кейсы
|
||
const [cases, setCases] = useState<Case[]>([]);
|
||
const [casesLoading, setCasesLoading] = useState<boolean>(false);
|
||
|
||
// Онлайн/сервер (по аналогии с Marketplace)
|
||
const [isOnline, setIsOnline] = useState<boolean>(false);
|
||
const [playerServer, setPlayerServer] = useState<Server | null>(null);
|
||
const [onlineCheckLoading, setOnlineCheckLoading] = useState<boolean>(true);
|
||
|
||
// Рулетка
|
||
const [isOpening, setIsOpening] = useState<boolean>(false);
|
||
const [selectedCase, setSelectedCase] = useState<Case | null>(null);
|
||
|
||
const [rouletteOpen, setRouletteOpen] = useState(false);
|
||
const [rouletteCaseItems, setRouletteCaseItems] = useState<CaseItem[]>([]);
|
||
const [rouletteReward, setRouletteReward] = useState<CaseItem | null>(null);
|
||
|
||
// Уведомления
|
||
const [notification, setNotification] = useState<{
|
||
open: boolean;
|
||
message: string;
|
||
type: 'success' | 'error';
|
||
}>({
|
||
open: false,
|
||
message: '',
|
||
type: 'success',
|
||
});
|
||
|
||
const VISIBLE_ITEMS = 21; // сколько элементов в линии
|
||
const CENTER_INDEX = Math.floor(VISIBLE_ITEMS / 2);
|
||
|
||
// Функция для загрузки плащей из магазина
|
||
const loadStoreCapes = async () => {
|
||
try {
|
||
const capes = await fetchCapesStore();
|
||
setStoreCapes(capes);
|
||
} catch (error) {
|
||
console.error('Ошибка при получении плащей магазина:', error);
|
||
setStoreCapes([]);
|
||
}
|
||
};
|
||
|
||
// Функция для загрузки плащей пользователя
|
||
const loadUserCapes = async (username: string) => {
|
||
try {
|
||
const userCapes = await fetchCapes(username);
|
||
setUserCapes(userCapes);
|
||
} catch (error) {
|
||
console.error('Ошибка при получении плащей пользователя:', error);
|
||
setUserCapes([]);
|
||
}
|
||
};
|
||
|
||
const handlePurchaseCape = async (cape_id: string) => {
|
||
try {
|
||
await purchaseCape(username, cape_id);
|
||
await loadUserCapes(username);
|
||
setNotification({
|
||
open: true,
|
||
message: 'Плащ успешно куплен!',
|
||
type: 'success',
|
||
});
|
||
} catch (error) {
|
||
console.error('Ошибка при покупке плаща:', error);
|
||
setNotification({
|
||
open: true,
|
||
message:
|
||
error instanceof Error ? error.message : 'Ошибка при покупке плаща',
|
||
type: 'error',
|
||
});
|
||
}
|
||
};
|
||
|
||
// Загрузка кейсов
|
||
const loadCases = async () => {
|
||
try {
|
||
setCasesLoading(true);
|
||
const casesData = await fetchCases();
|
||
setCases(casesData);
|
||
} catch (error) {
|
||
console.error('Ошибка при получении кейсов:', error);
|
||
setCases([]);
|
||
} finally {
|
||
setCasesLoading(false);
|
||
}
|
||
};
|
||
|
||
// Проверка онлайна игрока (по аналогии с Marketplace.tsx)
|
||
const checkPlayerStatus = async () => {
|
||
if (!username) return;
|
||
|
||
try {
|
||
setOnlineCheckLoading(true);
|
||
const { online, server } = await getPlayerServer(username);
|
||
setIsOnline(online);
|
||
setPlayerServer(server || null);
|
||
} catch (error) {
|
||
console.error('Ошибка при проверке онлайн-статуса:', error);
|
||
setIsOnline(false);
|
||
setPlayerServer(null);
|
||
} finally {
|
||
setOnlineCheckLoading(false);
|
||
}
|
||
};
|
||
|
||
// Загружаем базовые данные при монтировании
|
||
useEffect(() => {
|
||
const savedConfig = localStorage.getItem('launcher_config');
|
||
if (savedConfig) {
|
||
const config = JSON.parse(savedConfig);
|
||
if (config.uuid && config.username) {
|
||
setUsername(config.username);
|
||
setUuid(config.uuid);
|
||
|
||
setLoading(true);
|
||
|
||
Promise.all([
|
||
loadStoreCapes(),
|
||
loadUserCapes(config.username),
|
||
loadCases(),
|
||
])
|
||
.catch((err) => console.error(err))
|
||
.finally(() => {
|
||
setLoading(false);
|
||
});
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
// Проверяем онлайн после того, как знаем username
|
||
useEffect(() => {
|
||
if (username) {
|
||
checkPlayerStatus();
|
||
}
|
||
}, [username]);
|
||
|
||
// Фильтруем плащи, которые уже куплены пользователем
|
||
const availableCapes = storeCapes.filter(
|
||
(storeCape) =>
|
||
!userCapes.some((userCape) => userCape.cape_id === storeCape.id),
|
||
);
|
||
|
||
// Генерация массива предметов для рулетки
|
||
const generateRouletteItems = (
|
||
allItems: Case['items'],
|
||
winningItemMaterial: string,
|
||
): Case['items'] => {
|
||
if (!allItems || allItems.length === 0) return [];
|
||
|
||
const result: Case['items'] = [];
|
||
for (let i = 0; i < VISIBLE_ITEMS; i++) {
|
||
const randomItem = allItems[Math.floor(Math.random() * allItems.length)];
|
||
result.push(randomItem);
|
||
}
|
||
|
||
// Принудительно ставим выигрышный предмет в центр
|
||
const winningSource =
|
||
allItems.find((i) => i.material === winningItemMaterial) || allItems[0];
|
||
result[CENTER_INDEX] = winningSource;
|
||
|
||
return result;
|
||
};
|
||
|
||
const handleOpenCase = async (caseData: Case) => {
|
||
if (!username) {
|
||
setNotification({
|
||
open: true,
|
||
message: 'Не найдено имя игрока. Авторизуйтесь в лаунчере.',
|
||
type: 'error',
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!isOnline || !playerServer) {
|
||
setNotification({
|
||
open: true,
|
||
message: 'Для открытия кейсов необходимо находиться на сервере в игре.',
|
||
type: 'error',
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (isOpening) return;
|
||
|
||
try {
|
||
setIsOpening(true);
|
||
|
||
// 1. получаем полный кейс
|
||
const fullCase = await fetchCase(caseData.id);
|
||
const caseItems: CaseItem[] = fullCase.items || [];
|
||
setSelectedCase(fullCase);
|
||
|
||
// 2. открываем кейс на бэке
|
||
const result = await openCase(fullCase.id, username, playerServer.id);
|
||
|
||
// 3. сохраняем данные для рулетки
|
||
setRouletteCaseItems(caseItems);
|
||
setRouletteReward(result.reward);
|
||
setRouletteOpen(true);
|
||
|
||
// 4. уведомление
|
||
setNotification({
|
||
open: true,
|
||
message: result.message || 'Кейс открыт!',
|
||
type: 'success',
|
||
});
|
||
|
||
setIsOpening(false);
|
||
} catch (error) {
|
||
console.error('Ошибка при открытии кейса:', error);
|
||
setNotification({
|
||
open: true,
|
||
message:
|
||
error instanceof Error ? error.message : 'Ошибка при открытии кейса',
|
||
type: 'error',
|
||
});
|
||
setIsOpening(false);
|
||
}
|
||
};
|
||
|
||
const handleCloseNotification = () => {
|
||
setNotification((prev) => ({ ...prev, open: false }));
|
||
};
|
||
|
||
const handleCloseRoulette = () => {
|
||
setRouletteOpen(false);
|
||
};
|
||
|
||
return (
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '2vw',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
width: '100%',
|
||
height: '100%',
|
||
}}
|
||
>
|
||
{(loading || onlineCheckLoading) && (
|
||
<FullScreenLoader message="Загрузка магазина..." />
|
||
)}
|
||
|
||
{!loading && !onlineCheckLoading && (
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
flexWrap: 'wrap',
|
||
alignContent: 'flex-start',
|
||
width: '90%',
|
||
height: '80%',
|
||
gap: '2vw',
|
||
}}
|
||
>
|
||
{/* Блок кейсов */}
|
||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||
Кейсы
|
||
</Typography>
|
||
|
||
{!isOnline && (
|
||
<Typography variant="body1" color="error" sx={{ mb: 2 }}>
|
||
Для открытия кейсов вам необходимо находиться на одном из серверов
|
||
игры. Зайдите в игру и обновите страницу.
|
||
</Typography>
|
||
)}
|
||
|
||
{casesLoading ? (
|
||
<FullScreenLoader fullScreen={false} message="Загрузка кейсов..." />
|
||
) : cases.length > 0 ? (
|
||
<Grid container spacing={2} sx={{ mb: 4 }}>
|
||
{cases.map((c) => (
|
||
<Grid item xs={12} sm={6} md={4} lg={3} key={c.id}>
|
||
{/* НОВАЯ КАРТОЧКА */}
|
||
<Card
|
||
sx={{
|
||
position: 'relative',
|
||
bgcolor: 'rgba(5, 5, 15, 0.96)',
|
||
borderRadius: '20px',
|
||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||
boxShadow: '0 18px 45px rgba(0, 0, 0, 0.8)',
|
||
overflow: 'hidden',
|
||
transition:
|
||
'transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease',
|
||
'&:hover': {
|
||
transform: 'translateY(-6px)',
|
||
boxShadow: '0 26px 60px rgba(0, 0, 0, 0.95)',
|
||
borderColor: 'rgba(255, 255, 255, 0.18)',
|
||
},
|
||
}}
|
||
>
|
||
{/* верхний “свет” */}
|
||
<Box
|
||
sx={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
pointerEvents: 'none',
|
||
background:
|
||
'radial-gradient(circle at top, rgba(255,255,255,0.13), transparent 55%)',
|
||
}}
|
||
/>
|
||
|
||
{c.image_url && (
|
||
<Box
|
||
sx={{
|
||
position: 'relative',
|
||
p: '0.9vw',
|
||
pb: 0,
|
||
}}
|
||
>
|
||
<Box
|
||
sx={{
|
||
borderRadius: '16px',
|
||
overflow: 'hidden',
|
||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||
background:
|
||
'linear-gradient(135deg, rgba(40,40,80,0.9), rgba(15,15,35,0.9))',
|
||
}}
|
||
>
|
||
<CardMedia
|
||
component="img"
|
||
image={c.image_url}
|
||
alt={c.name}
|
||
sx={{
|
||
width: '100%',
|
||
height: '11vw',
|
||
minHeight: '140px',
|
||
objectFit: 'cover',
|
||
filter: 'saturate(1.1)',
|
||
}}
|
||
/>
|
||
</Box>
|
||
|
||
{/* маленький бейдж сверху картинки */}
|
||
<Box
|
||
sx={{
|
||
position: 'absolute',
|
||
top: '1.2vw',
|
||
left: '1.6vw',
|
||
px: 1.2,
|
||
py: 0.4,
|
||
borderRadius: '999px',
|
||
fontSize: '0.7rem',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: 0.6,
|
||
bgcolor: 'rgba(0, 0, 0, 0.6)',
|
||
border: '1px solid rgba(255, 255, 255, 0.4)',
|
||
color: 'white',
|
||
}}
|
||
>
|
||
Кейс
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
<CardContent
|
||
sx={{
|
||
position: 'relative',
|
||
zIndex: 1,
|
||
pt: c.image_url ? '0.9vw' : '1.4vw',
|
||
pb: '1.3vw',
|
||
}}
|
||
>
|
||
<Typography
|
||
variant="h6"
|
||
color="white"
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
fontSize: '1.05rem',
|
||
mb: 0.8,
|
||
}}
|
||
>
|
||
{c.name}
|
||
</Typography>
|
||
|
||
{c.description && (
|
||
<Typography
|
||
variant="body2"
|
||
color="white"
|
||
sx={{
|
||
opacity: 0.75,
|
||
fontSize: '0.85rem',
|
||
mb: 1.5,
|
||
minHeight: '40px',
|
||
}}
|
||
>
|
||
{c.description}
|
||
</Typography>
|
||
)}
|
||
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
mb: 1,
|
||
}}
|
||
>
|
||
<Typography
|
||
variant="body2"
|
||
color="white"
|
||
sx={{ opacity: 0.8, fontSize: '0.85rem' }}
|
||
>
|
||
Цена
|
||
</Typography>
|
||
<CoinsDisplay
|
||
value={c.price}
|
||
size="small"
|
||
autoUpdate={false}
|
||
showTooltip={true}
|
||
/>
|
||
</Box>
|
||
|
||
{typeof c.items_count === 'number' && (
|
||
<Typography
|
||
variant="body2"
|
||
color="white"
|
||
sx={{ opacity: 0.6, fontSize: '0.8rem', mb: 1.4 }}
|
||
>
|
||
Предметов в кейсе: {c.items_count}
|
||
</Typography>
|
||
)}
|
||
|
||
<Button
|
||
variant="contained"
|
||
fullWidth
|
||
sx={{
|
||
mt: 0.5,
|
||
borderRadius: '999px',
|
||
py: '0.45vw',
|
||
color: 'white',
|
||
background:
|
||
'linear-gradient(135deg, rgb(255, 77, 77), rgb(255, 120, 100))',
|
||
'&:hover': {
|
||
background:
|
||
'linear-gradient(135deg, rgba(255, 77, 77, 0.85), rgba(255, 120, 100, 0.9))',
|
||
},
|
||
fontFamily: 'Benzin-Bold',
|
||
fontSize: '0.9rem',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: 0.8,
|
||
}}
|
||
disabled={!isOnline || isOpening}
|
||
onClick={() => handleOpenCase(c)}
|
||
>
|
||
{isOpening && selectedCase?.id === c.id
|
||
? 'Открываем...'
|
||
: 'Открыть кейс'}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
))}
|
||
</Grid>
|
||
) : (
|
||
<Typography>Кейсы временно недоступны.</Typography>
|
||
)}
|
||
|
||
{/* Блок плащей (как был) */}
|
||
<Typography variant="h6">Доступные плащи</Typography>
|
||
{availableCapes.length > 0 ? (
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
flexDirection: 'row',
|
||
gap: '2vw',
|
||
flexWrap: 'wrap',
|
||
}}
|
||
>
|
||
{availableCapes.map((cape) => (
|
||
<CapeCard
|
||
key={cape.id}
|
||
cape={cape}
|
||
mode="shop"
|
||
onAction={handlePurchaseCape}
|
||
/>
|
||
))}
|
||
</Box>
|
||
) : (
|
||
<Typography>У вас уже есть все доступные плащи!</Typography>
|
||
)}
|
||
</Box>
|
||
)}
|
||
|
||
{/* Компонент с анимацией рулетки */}
|
||
<CaseRoulette
|
||
open={rouletteOpen}
|
||
onClose={handleCloseRoulette}
|
||
caseName={selectedCase?.name}
|
||
items={rouletteCaseItems}
|
||
reward={rouletteReward}
|
||
/>
|
||
|
||
{/* Уведомления */}
|
||
<Snackbar
|
||
open={notification.open}
|
||
autoHideDuration={6000}
|
||
onClose={handleCloseNotification}
|
||
>
|
||
<Alert
|
||
onClose={handleCloseNotification}
|
||
severity={notification.type}
|
||
sx={{ width: '100%' }}
|
||
>
|
||
{notification.message}
|
||
</Alert>
|
||
</Snackbar>
|
||
</Box>
|
||
);
|
||
}
|