676 lines
21 KiB
TypeScript
676 lines
21 KiB
TypeScript
import { Box, Typography, Button, Grid, Snackbar, Alert } from '@mui/material';
|
||
import {
|
||
Cape,
|
||
fetchCapes,
|
||
fetchCapesStore,
|
||
purchaseCape,
|
||
StoreCape,
|
||
Case,
|
||
CaseItem,
|
||
fetchCases,
|
||
fetchCase,
|
||
openCase,
|
||
Server,
|
||
fetchPlayer,
|
||
BonusType,
|
||
UserBonus,
|
||
fetchBonusTypes,
|
||
fetchUserBonuses,
|
||
purchaseBonus,
|
||
upgradeBonus,
|
||
toggleBonusActivation,
|
||
} from '../api';
|
||
import { useEffect, useState } from 'react';
|
||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||
import { getPlayerServer } from '../utils/playerOnlineCheck';
|
||
import CaseRoulette from '../components/CaseRoulette';
|
||
import BonusShopItem from '../components/BonusShopItem';
|
||
import ShopItem from '../components/ShopItem';
|
||
|
||
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 [playerSkinUrl, setPlayerSkinUrl] = useState<string>('');
|
||
|
||
// Прокачка
|
||
|
||
const [bonusTypes, setBonusTypes] = useState<BonusType[]>([]);
|
||
const [userBonuses, setUserBonuses] = useState<UserBonus[]>([]);
|
||
const [bonusesLoading, setBonusesLoading] = useState<boolean>(false);
|
||
const [processingBonusIds, setProcessingBonusIds] = useState<string[]>([]);
|
||
|
||
// Кейсы
|
||
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 loadBonuses = async (username: string) => {
|
||
try {
|
||
setBonusesLoading(true);
|
||
const [types, user] = await Promise.all([
|
||
fetchBonusTypes(),
|
||
fetchUserBonuses(username),
|
||
]);
|
||
setBonusTypes(types);
|
||
setUserBonuses(user);
|
||
} catch (error) {
|
||
console.error('Ошибка при получении прокачек:', error);
|
||
setNotification({
|
||
open: true,
|
||
message:
|
||
error instanceof Error
|
||
? error.message
|
||
: 'Ошибка при загрузке прокачки',
|
||
type: 'error',
|
||
});
|
||
} finally {
|
||
setBonusesLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadPlayerSkin = async (uuid: string) => {
|
||
try {
|
||
const player = await fetchPlayer(uuid);
|
||
setPlayerSkinUrl(player.skin_url);
|
||
} catch (error) {
|
||
console.error('Ошибка при получении скина игрока:', error);
|
||
setPlayerSkinUrl('');
|
||
}
|
||
};
|
||
|
||
// Функция для загрузки плащей из магазина
|
||
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(),
|
||
loadPlayerSkin(config.uuid),
|
||
loadBonuses(config.username),
|
||
])
|
||
.catch((err) => console.error(err))
|
||
.finally(() => {
|
||
setLoading(false);
|
||
});
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
// Проверяем онлайн после того, как знаем username
|
||
useEffect(() => {
|
||
if (username) {
|
||
checkPlayerStatus();
|
||
}
|
||
}, [username]);
|
||
|
||
const withProcessing = async (id: string, fn: () => Promise<void>) => {
|
||
setProcessingBonusIds((prev) => [...prev, id]);
|
||
try {
|
||
await fn();
|
||
} finally {
|
||
setProcessingBonusIds((prev) => prev.filter((x) => x !== id));
|
||
}
|
||
};
|
||
|
||
const handlePurchaseBonus = async (bonusTypeId: string) => {
|
||
if (!username) {
|
||
setNotification({
|
||
open: true,
|
||
message: 'Не найдено имя игрока. Авторизуйтесь в лаунчере.',
|
||
type: 'error',
|
||
});
|
||
return;
|
||
}
|
||
|
||
await withProcessing(bonusTypeId, async () => {
|
||
try {
|
||
const res = await purchaseBonus(username, bonusTypeId);
|
||
setNotification({
|
||
open: true,
|
||
message: res.message || 'Прокачка успешно куплена!',
|
||
type: 'success',
|
||
});
|
||
await loadBonuses(username);
|
||
} catch (error) {
|
||
console.error('Ошибка при покупке прокачки:', error);
|
||
setNotification({
|
||
open: true,
|
||
message:
|
||
error instanceof Error
|
||
? error.message
|
||
: 'Ошибка при покупке прокачки',
|
||
type: 'error',
|
||
});
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleUpgradeBonus = async (bonusId: string) => {
|
||
if (!username) return;
|
||
|
||
await withProcessing(bonusId, async () => {
|
||
try {
|
||
await upgradeBonus(username, bonusId);
|
||
setNotification({
|
||
open: true,
|
||
message: 'Бонус улучшен!',
|
||
type: 'success',
|
||
});
|
||
await loadBonuses(username);
|
||
} catch (error) {
|
||
console.error('Ошибка при улучшении бонуса:', error);
|
||
setNotification({
|
||
open: true,
|
||
message:
|
||
error instanceof Error
|
||
? error.message
|
||
: 'Ошибка при улучшении бонуса',
|
||
type: 'error',
|
||
});
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleToggleBonusActivation = async (bonusId: string) => {
|
||
if (!username) return;
|
||
|
||
await withProcessing(bonusId, async () => {
|
||
try {
|
||
await toggleBonusActivation(username, bonusId);
|
||
await loadBonuses(username);
|
||
} catch (error) {
|
||
console.error('Ошибка при переключении бонуса:', error);
|
||
setNotification({
|
||
open: true,
|
||
message:
|
||
error instanceof Error
|
||
? error.message
|
||
: 'Ошибка при переключении бонуса',
|
||
type: 'error',
|
||
});
|
||
}
|
||
});
|
||
};
|
||
|
||
// Фильтруем плащи, которые уже куплены пользователем
|
||
const availableCapes = storeCapes.filter(
|
||
(storeCape) =>
|
||
!userCapes.some((userCape) => userCape.cape_id === storeCape.id),
|
||
);
|
||
|
||
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',
|
||
gap: '2vw',
|
||
width: '100%',
|
||
height: '100%',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
}}
|
||
>
|
||
{(loading || onlineCheckLoading) && (
|
||
<FullScreenLoader message="Загрузка магазина..." />
|
||
)}
|
||
|
||
{!loading && !onlineCheckLoading && (
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
width: '90%',
|
||
height: '80%',
|
||
gap: '2vw',
|
||
overflow: 'auto',
|
||
paddingTop: '3vh',
|
||
paddingBottom: '10vh',
|
||
paddingLeft: '5vw',
|
||
paddingRight: '5vw',
|
||
}}
|
||
>
|
||
{/* Блок прокачки */}
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 2,
|
||
}}
|
||
>
|
||
<Typography
|
||
variant="h6"
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
backgroundImage:
|
||
'linear-gradient(136deg, rgb(242,113,33), rgb(233,64,87), rgb(138,35,135))',
|
||
WebkitBackgroundClip: 'text',
|
||
WebkitTextFillColor: 'transparent',
|
||
}}
|
||
>
|
||
Прокачка
|
||
</Typography>
|
||
|
||
{bonusesLoading ? (
|
||
<FullScreenLoader
|
||
fullScreen={false}
|
||
message="Загрузка прокачки..."
|
||
/>
|
||
) : bonusTypes.length > 0 ? (
|
||
<Grid container spacing={2} sx={{ mb: 4 }}>
|
||
{bonusTypes.map((bt) => {
|
||
const userBonus = userBonuses.find(
|
||
(ub) => ub.bonus_type_id === bt.id,
|
||
);
|
||
const owned = !!userBonus;
|
||
|
||
const level = owned ? userBonus!.level : 0;
|
||
const effectValue = owned
|
||
? userBonus!.effect_value
|
||
: bt.base_effect_value;
|
||
const nextEffectValue =
|
||
owned && userBonus!.can_upgrade
|
||
? bt.base_effect_value +
|
||
userBonus!.level * bt.effect_increment
|
||
: undefined;
|
||
|
||
const isActive = owned ? userBonus!.is_active : false;
|
||
const isPermanent = owned
|
||
? userBonus!.is_permanent
|
||
: bt.duration === 0;
|
||
|
||
const cardId = owned ? userBonus!.id : bt.id;
|
||
const processing = processingBonusIds.includes(cardId);
|
||
|
||
return (
|
||
<Grid item xs={12} sm={6} md={4} lg={3} key={bt.id}>
|
||
<BonusShopItem
|
||
id={cardId}
|
||
name={bt.name}
|
||
description={bt.description}
|
||
imageUrl={bt.image_url}
|
||
level={level}
|
||
effectValue={effectValue}
|
||
nextEffectValue={nextEffectValue}
|
||
price={bt.price}
|
||
upgradePrice={bt.upgrade_price}
|
||
canUpgrade={userBonus?.can_upgrade ?? false}
|
||
mode={owned ? 'upgrade' : 'buy'}
|
||
isActive={isActive}
|
||
isPermanent={isPermanent}
|
||
disabled={processing}
|
||
onBuy={
|
||
!owned ? () => handlePurchaseBonus(bt.id) : undefined
|
||
}
|
||
onUpgrade={
|
||
owned
|
||
? () => handleUpgradeBonus(userBonus!.id)
|
||
: undefined
|
||
}
|
||
onToggleActive={
|
||
owned
|
||
? () => handleToggleBonusActivation(userBonus!.id)
|
||
: undefined
|
||
}
|
||
/>
|
||
</Grid>
|
||
);
|
||
})}
|
||
</Grid>
|
||
) : (
|
||
<Typography>Прокачка временно недоступна.</Typography>
|
||
)}
|
||
</Box>
|
||
|
||
{/* Блок кейсов */}
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
alignItems: 'flex-start',
|
||
gap: 2,
|
||
mb: 1,
|
||
flexDirection: 'column',
|
||
}}
|
||
>
|
||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||
<Typography
|
||
variant="h6"
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
backgroundImage:
|
||
'linear-gradient(136deg, rgb(242,113,33), rgb(233,64,87), rgb(138,35,135))',
|
||
WebkitBackgroundClip: 'text',
|
||
WebkitTextFillColor: 'transparent',
|
||
}}
|
||
>
|
||
Кейсы
|
||
</Typography>
|
||
|
||
{!isOnline && (
|
||
<Button
|
||
variant="outlined"
|
||
size="small"
|
||
sx={{
|
||
transition: 'transform 0.3s ease',
|
||
width: '60%',
|
||
background:
|
||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||
fontFamily: 'Benzin-Bold',
|
||
borderRadius: '2.5vw',
|
||
fontSize: '0.8em',
|
||
color: 'white',
|
||
'&:hover': {
|
||
transform: 'scale(1.1)',
|
||
},
|
||
}}
|
||
onClick={() => {
|
||
checkPlayerStatus(); // обновляем онлайн-статус
|
||
loadCases(); // обновляем ТОЛЬКО кейсы
|
||
}}
|
||
>
|
||
Обновить
|
||
</Button>
|
||
)}
|
||
</Box>
|
||
|
||
{!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}>
|
||
<ShopItem
|
||
type="case"
|
||
id={c.id}
|
||
name={c.name}
|
||
description={c.description}
|
||
imageUrl={c.image_url}
|
||
price={c.price}
|
||
itemsCount={c.items_count}
|
||
isOpening={isOpening && selectedCase?.id === c.id}
|
||
disabled={!isOnline || isOpening}
|
||
onClick={() => handleOpenCase(c)}
|
||
/>
|
||
</Grid>
|
||
))}
|
||
</Grid>
|
||
) : (
|
||
<Typography>Кейсы временно недоступны.</Typography>
|
||
)}
|
||
</Box>
|
||
|
||
{/* Блок плащей (как был) */}
|
||
|
||
{/* Блок плащей */}
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
<Typography
|
||
variant="h6"
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
backgroundImage:
|
||
'linear-gradient(136deg, rgb(242,113,33), rgb(233,64,87), rgb(138,35,135))',
|
||
WebkitBackgroundClip: 'text',
|
||
WebkitTextFillColor: 'transparent',
|
||
}}
|
||
>
|
||
Плащи
|
||
</Typography>
|
||
|
||
{availableCapes.length > 0 ? (
|
||
<Grid container spacing={2}>
|
||
{availableCapes.map((cape) => (
|
||
<Grid item xs={12} sm={6} md={4} lg={3} key={cape.id}>
|
||
<ShopItem
|
||
type="cape"
|
||
id={cape.id}
|
||
name={cape.name}
|
||
description={cape.description}
|
||
imageUrl={cape.image_url}
|
||
price={cape.price}
|
||
disabled={false}
|
||
playerSkinUrl={playerSkinUrl}
|
||
onClick={() => handlePurchaseCape(cape.id)}
|
||
/>
|
||
</Grid>
|
||
))}
|
||
</Grid>
|
||
) : (
|
||
<Typography>У вас уже есть все доступные плащи!</Typography>
|
||
)}
|
||
</Box>
|
||
</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>
|
||
);
|
||
}
|