add bonuses in shop

This commit is contained in:
2025-12-07 20:11:41 +05:00
parent 3e03c1024d
commit 833444df2e
3 changed files with 705 additions and 15 deletions

View File

@ -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 {

View 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;

View File

@ -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={{