diff --git a/src/renderer/api.ts b/src/renderer/api.ts index 6e8de04..042ed70 100644 --- a/src/renderer/api.ts +++ b/src/renderer/api.ts @@ -207,6 +207,190 @@ export interface MeResponse { 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 { + 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 { + 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 { diff --git a/src/renderer/components/BonusShopItem.tsx b/src/renderer/components/BonusShopItem.tsx new file mode 100644 index 0000000..6d2466b --- /dev/null +++ b/src/renderer/components/BonusShopItem.tsx @@ -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 = ({ + 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 ( + + {/* верхний “свет” */} + + + {imageUrl && ( + + + + + + )} + + + + + {name} + + + + Уровень: {level} + + + {description && ( + + {description} + + )} + + + + Текущий эффект: {effectValue.toLocaleString('ru-RU')} + + + {typeof nextEffectValue === 'number' && + !isBuyMode && + canUpgrade && ( + + Следующий уровень:{' '} + {nextEffectValue.toLocaleString('ru-RU')} + + )} + + + + + {isActive ? 'Бонус активен' : 'Бонус не активен'} + + + + + + {isBuyMode ? 'Цена покупки' : 'Цена улучшения'} + + {displayedPrice !== undefined && ( + + )} + + + {!isBuyMode && onToggleActive && ( + + )} + + + + + + ); +}; + +export default BonusShopItem; diff --git a/src/renderer/pages/Shop.tsx b/src/renderer/pages/Shop.tsx index 1a5b12a..1c3ce59 100644 --- a/src/renderer/pages/Shop.tsx +++ b/src/renderer/pages/Shop.tsx @@ -1,17 +1,4 @@ -import { - Box, - Typography, - Button, - Grid, - Card, - CardMedia, - CardContent, - Snackbar, - Alert, - Dialog, - DialogContent, -} from '@mui/material'; -import CapeCard from '../components/CapeCard'; +import { Box, Typography, Button, Grid, Snackbar, Alert } from '@mui/material'; import { Cape, fetchCapes, @@ -25,12 +12,19 @@ import { 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 CoinsDisplay from '../components/CoinsDisplay'; +import BonusShopItem from '../components/BonusShopItem'; import ShopItem from '../components/ShopItem'; function getRarityByWeight( @@ -68,6 +62,13 @@ export default function Shop() { const [playerSkinUrl, setPlayerSkinUrl] = useState(''); + // Прокачка + + const [bonusTypes, setBonusTypes] = useState([]); + const [userBonuses, setUserBonuses] = useState([]); + const [bonusesLoading, setBonusesLoading] = useState(false); + const [processingBonusIds, setProcessingBonusIds] = useState([]); + // Кейсы const [cases, setCases] = useState([]); const [casesLoading, setCasesLoading] = useState(false); @@ -96,6 +97,30 @@ export default function Shop() { 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); @@ -196,6 +221,7 @@ export default function Shop() { loadUserCapes(config.username), loadCases(), loadPlayerSkin(config.uuid), + loadBonuses(config.username), ]) .catch((err) => console.error(err)) .finally(() => { @@ -212,6 +238,95 @@ export default function Shop() { } }, [username]); + const withProcessing = async (id: string, fn: () => Promise) => { + 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) => @@ -312,6 +427,87 @@ export default function Shop() { paddingRight: '5vw', }} > + {/* Блок прокачки */} + + Прокачка + + {bonusesLoading ? ( + + ) : bonusTypes.length > 0 ? ( + + {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 ( + + handlePurchaseBonus(bt.id) : undefined + } + onUpgrade={ + owned + ? () => handleUpgradeBonus(userBonus!.id) + : undefined + } + onToggleActive={ + owned + ? () => handleToggleBonusActivation(userBonus!.id) + : undefined + } + /> + + ); + })} + + ) : ( + Прокачка временно недоступна. + )} + + {/* Блок кейсов */}