add bonuses in shop
This commit is contained in:
@ -207,6 +207,190 @@ export interface MeResponse {
|
|||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== БОНУСЫ / ПРОКАЧКА =====
|
||||||
|
|
||||||
|
export interface UserBonus {
|
||||||
|
id: string;
|
||||||
|
bonus_type_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
effect_type: string;
|
||||||
|
effect_value: number;
|
||||||
|
level: number;
|
||||||
|
purchased_at: string;
|
||||||
|
can_upgrade: boolean;
|
||||||
|
upgrade_price: number;
|
||||||
|
is_active: boolean;
|
||||||
|
is_permanent: boolean;
|
||||||
|
expires_at?: string;
|
||||||
|
image_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserBonusesResponse = {
|
||||||
|
bonuses: UserBonus[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BonusType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
effect_type: string;
|
||||||
|
base_effect_value: number;
|
||||||
|
effect_increment: number;
|
||||||
|
price: number;
|
||||||
|
upgrade_price: number;
|
||||||
|
duration: number;
|
||||||
|
max_level: number;
|
||||||
|
image_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BonusTypesResponse = {
|
||||||
|
bonuses: BonusType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchBonusTypes(): Promise<BonusType[]> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/bonuses/types`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Не удалось получить список прокачек');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: BonusTypesResponse = await response.json();
|
||||||
|
return data.bonuses || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserBonuses(username: string): Promise<UserBonus[]> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/bonuses/user/${username}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Не удалось получить бонусы игрока');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: UserBonusesResponse = await response.json();
|
||||||
|
return data.bonuses || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purchaseBonus(
|
||||||
|
username: string,
|
||||||
|
bonus_type_id: string,
|
||||||
|
): Promise<{
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
remaining_coins?: number;
|
||||||
|
}> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/bonuses/purchase`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
bonus_type_id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let msg = 'Не удалось купить прокачку';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.message) msg = errorData.message;
|
||||||
|
else if (Array.isArray(errorData.detail)) {
|
||||||
|
msg = errorData.detail.map((d: any) => d.msg).join(', ');
|
||||||
|
} else if (typeof errorData.detail === 'string') {
|
||||||
|
msg = errorData.detail;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// оставляем дефолтное сообщение
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upgradeBonus(
|
||||||
|
username: string,
|
||||||
|
bonus_id: string,
|
||||||
|
): Promise<{
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
bonus_id: string;
|
||||||
|
new_level?: number;
|
||||||
|
}> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/bonuses/upgrade`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
bonus_id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let msg = 'Не удалось улучшить бонус';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.message) msg = errorData.message;
|
||||||
|
else if (Array.isArray(errorData.detail)) {
|
||||||
|
msg = errorData.detail.map((d: any) => d.msg).join(', ');
|
||||||
|
} else if (typeof errorData.detail === 'string') {
|
||||||
|
msg = errorData.detail;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// оставляем дефолтное сообщение
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleBonusActivation(
|
||||||
|
username: string,
|
||||||
|
bonus_id: string,
|
||||||
|
): Promise<{
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
bonus_id: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/api/bonuses/toggle-activation`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
bonus_id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let msg = 'Не удалось переключить бонус';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.message) msg = errorData.message;
|
||||||
|
else if (Array.isArray(errorData.detail)) {
|
||||||
|
msg = errorData.detail.map((d: any) => d.msg).join(', ');
|
||||||
|
} else if (typeof errorData.detail === 'string') {
|
||||||
|
msg = errorData.detail;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// оставляем дефолтное сообщение
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
// ===== КЕЙСЫ =====
|
// ===== КЕЙСЫ =====
|
||||||
|
|
||||||
export interface CaseItemMeta {
|
export interface CaseItemMeta {
|
||||||
|
|||||||
310
src/renderer/components/BonusShopItem.tsx
Normal file
310
src/renderer/components/BonusShopItem.tsx
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
CardMedia,
|
||||||
|
} from '@mui/material';
|
||||||
|
import CoinsDisplay from './CoinsDisplay';
|
||||||
|
|
||||||
|
export interface BonusShopItemProps {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
level: number;
|
||||||
|
effectValue: number;
|
||||||
|
nextEffectValue?: number;
|
||||||
|
|
||||||
|
// цена покупки и улучшения
|
||||||
|
price?: number;
|
||||||
|
upgradePrice: number;
|
||||||
|
canUpgrade: boolean;
|
||||||
|
|
||||||
|
mode?: 'buy' | 'upgrade';
|
||||||
|
|
||||||
|
isActive?: boolean;
|
||||||
|
isPermanent?: boolean;
|
||||||
|
|
||||||
|
imageUrl?: string;
|
||||||
|
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
onBuy?: () => void;
|
||||||
|
onUpgrade?: () => void;
|
||||||
|
onToggleActive?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BonusShopItem: React.FC<BonusShopItemProps> = ({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
level,
|
||||||
|
effectValue,
|
||||||
|
nextEffectValue,
|
||||||
|
price,
|
||||||
|
upgradePrice,
|
||||||
|
canUpgrade,
|
||||||
|
mode,
|
||||||
|
isActive = true,
|
||||||
|
isPermanent = false,
|
||||||
|
imageUrl,
|
||||||
|
disabled,
|
||||||
|
onBuy,
|
||||||
|
onUpgrade,
|
||||||
|
onToggleActive,
|
||||||
|
}) => {
|
||||||
|
const isBuyMode = mode === 'buy' || level === 0;
|
||||||
|
const buttonText = isBuyMode
|
||||||
|
? 'Купить'
|
||||||
|
: canUpgrade
|
||||||
|
? 'Улучшить'
|
||||||
|
: 'Макс. уровень';
|
||||||
|
const displayedPrice = isBuyMode ? (price ?? upgradePrice) : upgradePrice;
|
||||||
|
|
||||||
|
const buttonDisabled =
|
||||||
|
disabled ||
|
||||||
|
(isBuyMode
|
||||||
|
? !onBuy || displayedPrice === undefined
|
||||||
|
: !canUpgrade || !onUpgrade);
|
||||||
|
|
||||||
|
const handlePrimaryClick = () => {
|
||||||
|
if (buttonDisabled) return;
|
||||||
|
if (isBuyMode && onBuy) onBuy();
|
||||||
|
else if (!isBuyMode && onUpgrade) onUpgrade();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 280,
|
||||||
|
height: 420,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
|
||||||
|
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%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{imageUrl && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
p: '0.9vw',
|
||||||
|
pb: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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={imageUrl}
|
||||||
|
alt={name}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: '11vw',
|
||||||
|
minHeight: '140px',
|
||||||
|
objectFit: 'cover',
|
||||||
|
filter: 'saturate(1.1)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CardContent
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
pt: imageUrl ? '0.9vw' : '1.4vw',
|
||||||
|
pb: '1.3vw',
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
color="white"
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
fontSize: '1.05rem',
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="white"
|
||||||
|
sx={{
|
||||||
|
opacity: 0.7,
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Уровень: {level}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{description && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="white"
|
||||||
|
sx={{
|
||||||
|
opacity: 0.75,
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
mb: 1.4,
|
||||||
|
minHeight: 40,
|
||||||
|
maxHeight: 40,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ mb: 1.2 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="white"
|
||||||
|
sx={{ opacity: 0.8, fontSize: '0.8rem' }}
|
||||||
|
>
|
||||||
|
Текущий эффект: <b>{effectValue.toLocaleString('ru-RU')}</b>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{typeof nextEffectValue === 'number' &&
|
||||||
|
!isBuyMode &&
|
||||||
|
canUpgrade && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="white"
|
||||||
|
sx={{ opacity: 0.8, fontSize: '0.8rem', mt: 0.4 }}
|
||||||
|
>
|
||||||
|
Следующий уровень:{' '}
|
||||||
|
<b>{nextEffectValue.toLocaleString('ru-RU')}</b>
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color={isActive ? 'success.main' : 'warning.main'}
|
||||||
|
sx={{ fontSize: '0.78rem' }}
|
||||||
|
>
|
||||||
|
{isActive ? 'Бонус активен' : 'Бонус не активен'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="white"
|
||||||
|
sx={{ opacity: 0.8, fontSize: '0.85rem' }}
|
||||||
|
>
|
||||||
|
{isBuyMode ? 'Цена покупки' : 'Цена улучшения'}
|
||||||
|
</Typography>
|
||||||
|
{displayedPrice !== undefined && (
|
||||||
|
<CoinsDisplay
|
||||||
|
value={displayedPrice}
|
||||||
|
size="small"
|
||||||
|
autoUpdate={false}
|
||||||
|
showTooltip={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!isBuyMode && onToggleActive && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
mb: 1,
|
||||||
|
borderRadius: '999px',
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
}}
|
||||||
|
onClick={onToggleActive}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{isActive ? 'Выключить' : 'Включить'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
mt: 0.5,
|
||||||
|
borderRadius: '999px',
|
||||||
|
py: '0.45vw',
|
||||||
|
color: 'white',
|
||||||
|
background: !buttonDisabled
|
||||||
|
? 'linear-gradient(135deg, rgb(0, 160, 90), rgb(0, 200, 140))'
|
||||||
|
: 'linear-gradient(135deg, rgba(120,120,120,0.9), rgba(80,80,80,0.9))',
|
||||||
|
'&:hover': {
|
||||||
|
background: !buttonDisabled
|
||||||
|
? 'linear-gradient(135deg, rgba(0, 160, 90, 0.85), rgba(0, 200, 140, 0.9))'
|
||||||
|
: 'linear-gradient(135deg, rgba(120,120,120,0.9), rgba(80,80,80,0.9))',
|
||||||
|
},
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
}}
|
||||||
|
disabled={buttonDisabled}
|
||||||
|
onClick={handlePrimaryClick}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BonusShopItem;
|
||||||
@ -1,17 +1,4 @@
|
|||||||
import {
|
import { Box, Typography, Button, Grid, Snackbar, Alert } from '@mui/material';
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Button,
|
|
||||||
Grid,
|
|
||||||
Card,
|
|
||||||
CardMedia,
|
|
||||||
CardContent,
|
|
||||||
Snackbar,
|
|
||||||
Alert,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
} from '@mui/material';
|
|
||||||
import CapeCard from '../components/CapeCard';
|
|
||||||
import {
|
import {
|
||||||
Cape,
|
Cape,
|
||||||
fetchCapes,
|
fetchCapes,
|
||||||
@ -25,12 +12,19 @@ import {
|
|||||||
openCase,
|
openCase,
|
||||||
Server,
|
Server,
|
||||||
fetchPlayer,
|
fetchPlayer,
|
||||||
|
BonusType,
|
||||||
|
UserBonus,
|
||||||
|
fetchBonusTypes,
|
||||||
|
fetchUserBonuses,
|
||||||
|
purchaseBonus,
|
||||||
|
upgradeBonus,
|
||||||
|
toggleBonusActivation,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||||
import { getPlayerServer } from '../utils/playerOnlineCheck';
|
import { getPlayerServer } from '../utils/playerOnlineCheck';
|
||||||
import CaseRoulette from '../components/CaseRoulette';
|
import CaseRoulette from '../components/CaseRoulette';
|
||||||
import CoinsDisplay from '../components/CoinsDisplay';
|
import BonusShopItem from '../components/BonusShopItem';
|
||||||
import ShopItem from '../components/ShopItem';
|
import ShopItem from '../components/ShopItem';
|
||||||
|
|
||||||
function getRarityByWeight(
|
function getRarityByWeight(
|
||||||
@ -68,6 +62,13 @@ export default function Shop() {
|
|||||||
|
|
||||||
const [playerSkinUrl, setPlayerSkinUrl] = useState<string>('');
|
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 [cases, setCases] = useState<Case[]>([]);
|
||||||
const [casesLoading, setCasesLoading] = useState<boolean>(false);
|
const [casesLoading, setCasesLoading] = useState<boolean>(false);
|
||||||
@ -96,6 +97,30 @@ export default function Shop() {
|
|||||||
type: 'success',
|
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) => {
|
const loadPlayerSkin = async (uuid: string) => {
|
||||||
try {
|
try {
|
||||||
const player = await fetchPlayer(uuid);
|
const player = await fetchPlayer(uuid);
|
||||||
@ -196,6 +221,7 @@ export default function Shop() {
|
|||||||
loadUserCapes(config.username),
|
loadUserCapes(config.username),
|
||||||
loadCases(),
|
loadCases(),
|
||||||
loadPlayerSkin(config.uuid),
|
loadPlayerSkin(config.uuid),
|
||||||
|
loadBonuses(config.username),
|
||||||
])
|
])
|
||||||
.catch((err) => console.error(err))
|
.catch((err) => console.error(err))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@ -212,6 +238,95 @@ export default function Shop() {
|
|||||||
}
|
}
|
||||||
}, [username]);
|
}, [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(
|
const availableCapes = storeCapes.filter(
|
||||||
(storeCape) =>
|
(storeCape) =>
|
||||||
@ -312,6 +427,87 @@ export default function Shop() {
|
|||||||
paddingRight: '5vw',
|
paddingRight: '5vw',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Блок прокачки */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">Прокачка</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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
Reference in New Issue
Block a user