Files
popa-launcher/src/renderer/pages/Shop.tsx
2025-12-17 13:16:59 +05:00

772 lines
26 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.

import {
Box, Typography, Button, Grid,
FormControl, Select, MenuItem, InputLabel
} 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';
import { playBuySound, primeSounds } from '../utils/sounds';
import CustomNotification from '../components/Notifications/CustomNotification';
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
import { translateServer } from '../utils/serverTranslator';
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 [selectedCaseServerIp, setSelectedCaseServerIp] = useState<string>('');
// Уведомления
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',
});
// Прокачка
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);
if (!isNotificationsEnabled()) return;
setNotifMsg('Ошибка при загрузке прокачки!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
} 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);
playBuySound();
if (!isNotificationsEnabled()) return;
setNotifMsg('Плащ успешно куплен!');
setNotifSeverity('success');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
} catch (error) {
console.error('Ошибка при покупке плаща:', error);
if (!isNotificationsEnabled()) return;
setNotifMsg('Ошибка при покупке плаща!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
}
};
// Загрузка кейсов
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) {
if (!isNotificationsEnabled()) return;
setNotifMsg('Не найдено имя игрока. Авторизируйтесь в лаунчере!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
return;
}
await withProcessing(bonusTypeId, async () => {
try {
const res = await purchaseBonus(username, bonusTypeId);
playBuySound();
await loadBonuses(username);
if (!isNotificationsEnabled()) return;
setNotifMsg('Прокачка успешно куплена!');
setNotifSeverity('success');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
} catch (error) {
console.error('Ошибка при покупке прокачки:', error);
if (!isNotificationsEnabled()) return;
setNotifMsg('Ошибка при прокачке!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
}
});
};
const handleUpgradeBonus = async (bonusId: string) => {
if (!username) return;
await withProcessing(bonusId, async () => {
try {
await upgradeBonus(username, bonusId);
await loadBonuses(username);
if (!isNotificationsEnabled()) return;
setNotifMsg('Бонус улучшен!');
setNotifSeverity('success');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
} catch (error) {
console.error('Ошибка при улучшении бонуса:', error);
if (!isNotificationsEnabled()) return;
setNotifMsg('Ошибка при улучшении бонуса!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
}
});
};
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);
if (!isNotificationsEnabled()) return;
setNotifMsg('Ошибка при переключении бонуса!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
}
});
};
const caseServers = Array.from(
new Set(
(cases || [])
.flatMap((c) => c.server_ips || [])
.filter(Boolean),
),
);
useEffect(() => {
if (caseServers.length > 0) {
// если игрок онлайн — по умолчанию его сервер, если он есть в кейсах
const preferred =
playerServer?.ip && caseServers.includes(playerServer.ip)
? playerServer.ip
: caseServers[0];
setSelectedCaseServerIp(preferred);
}
}, [caseServers.length, playerServer?.ip]);
const filteredCases = (cases || []).filter((c) => {
const allowed = c.server_ips || [];
// если список пуст — значит кейс доступен везде
if (allowed.length === 0) return true;
// иначе — показываем только те, где выбранный сервер разрешён
return !!selectedCaseServerIp && allowed.includes(selectedCaseServerIp);
});
// Фильтруем плащи, которые уже куплены пользователем
const availableCapes = storeCapes.filter(
(storeCape) =>
!userCapes.some((userCape) => userCape.cape_id === storeCape.id),
);
const handleOpenCase = async (caseData: Case) => {
if (!username) {
if (!isNotificationsEnabled()) return;
setNotifMsg('Не найдено имя игрока. Авторизуйтесь в лаунчере!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
return;
}
if (!selectedCaseServerIp) {
if (!isNotificationsEnabled()) return;
setNotifMsg('Выберите сервер для открытия кейса!');
setNotifSeverity('warning');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
return;
}
const allowedIps = caseData.server_ips || [];
if (allowedIps.length > 0 && !allowedIps.includes(selectedCaseServerIp)) {
if (!isNotificationsEnabled()) return;
setNotifMsg(
`Этот кейс доступен на: ${allowedIps
.map((ip) => translateServer(`Server ${ip}`))
.join(', ')}`,
);
setNotifSeverity('warning');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
return;
}
if (isOpening) return;
try {
setIsOpening(true);
const fullCase = await fetchCase(caseData.id);
const caseItems: CaseItem[] = fullCase.items || [];
setSelectedCase(fullCase);
// ✅ открываем на выбранном сервере (даже если игрок не на сервере)
const result = await openCase(fullCase.id, username, selectedCaseServerIp);
setRouletteCaseItems(caseItems);
setRouletteReward(result.reward);
setRouletteOpen(true);
playBuySound();
if (!isNotificationsEnabled()) return;
setNotifMsg('Кейс открыт!');
setNotifSeverity('success');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
} catch (error) {
console.error('Ошибка при открытии кейса:', error);
if (!isNotificationsEnabled()) return;
setNotifMsg(String(error instanceof Error ? error.message : 'Ошибка при открытии кейса!'));
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
} finally {
setIsOpening(false);
}
};
const handleCloseNotification = () => {
setNotification((prev) => ({ ...prev, open: false }));
};
const handleCloseRoulette = () => {
setRouletteOpen(false);
};
useEffect(() => {
const onFirstUserGesture = () => primeSounds();
window.addEventListener('pointerdown', onFirstUserGesture, { once: true });
return () => window.removeEventListener('pointerdown', onFirstUserGesture);
}, []);
return (
<Box
sx={{
display: 'flex',
gap: '2vw',
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
}}
>
{(loading || onlineCheckLoading) && (
<FullScreenLoader message="Загрузка магазина..." />
)}
{!loading && !onlineCheckLoading && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
gap: '2vw',
overflow: 'auto',
paddingBottom: '5vw',
paddingLeft: '2.5vw',
paddingRight: '1.5vw',
}}
>
{/* Блок прокачки */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
mt: '2vh'
}}
>
<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>
{caseServers.length > 0 && (
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel id="cases-server-label" sx={{ fontFamily: 'Benzin-Bold', color: 'rgba(255,255,255,0.75)' }}>
Сервер
</InputLabel>
<Select
labelId="cases-server-label"
label="Сервер"
value={selectedCaseServerIp}
onChange={(e) => setSelectedCaseServerIp(String(e.target.value))}
MenuProps={{
PaperProps: {
sx: {
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',
},
'& .MuiMenuItem-root.Mui-selected': {
backgroundColor: 'rgba(242,113,33,0.16)',
},
'& .MuiMenuItem-root:hover': {
backgroundColor: 'rgba(233,64,205,0.14)',
},
},
},
}}
sx={{
borderRadius: '999px',
bgcolor: 'rgba(255,255,255,0.04)',
color: 'rgba(255,255,255,0.92)',
fontFamily: 'Benzin-Bold',
'& .MuiSelect-select': {
py: '0.9vw',
px: '1.2vw',
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(255,255,255,0.14)',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(242,113,33,0.55)',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(233,64,205,0.65)',
borderWidth: '2px',
},
'& .MuiSelect-icon': {
color: 'rgba(255,255,255,0.75)',
},
}}
>
{caseServers.map((ip) => (
<MenuItem key={ip} value={ip}>
{translateServer(`Server ${ip}`)}
</MenuItem>
))}
</Select>
</FormControl>
)}
</Box>
{casesLoading ? (
<FullScreenLoader fullScreen={false} message="Загрузка кейсов..." />
) : cases.length > 0 ? (
<Grid container spacing={2} sx={{ mb: 4 }}>
{filteredCases.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={isOpening || !selectedCaseServerIp}
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}
/>
{/* Уведомления */}
<CustomNotification
open={notifOpen}
message={notifMsg}
severity={notifSeverity}
position={notifPos}
onClose={() => setNotifOpen(false)}
autoHideDuration={2500}
/>
</Box>
);
}