add cases to Shop (roulette don't work :( )
This commit is contained in:
@ -207,6 +207,94 @@ export interface MeResponse {
|
|||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CaseItemMeta {
|
||||||
|
display_name?: string | null;
|
||||||
|
lore?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaseItem {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
material: string;
|
||||||
|
amount: number;
|
||||||
|
weight?: number;
|
||||||
|
meta?: CaseItemMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Case {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
price: number;
|
||||||
|
image_url?: string;
|
||||||
|
server_ids?: string[];
|
||||||
|
items_count?: number;
|
||||||
|
items?: CaseItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenCaseResponse {
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
operation_id: string;
|
||||||
|
balance: number;
|
||||||
|
reward: CaseItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== КЕЙСЫ =====
|
||||||
|
|
||||||
|
export async function fetchCases(): Promise<Case[]> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/cases`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Не удалось получить список кейсов');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если у тебя есть отдельный эндпоинт деталей кейса, можно использовать это:
|
||||||
|
export async function fetchCase(case_id: string): Promise<Case> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/cases/${case_id}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Не удалось получить информацию о кейсе');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openCase(
|
||||||
|
case_id: string,
|
||||||
|
username: string,
|
||||||
|
server_id: string,
|
||||||
|
): Promise<OpenCaseResponse> {
|
||||||
|
// Формируем URL с query-параметрами, как любит текущий бэкенд
|
||||||
|
const url = new URL(`${API_BASE_URL}/cases/${case_id}/open`);
|
||||||
|
url.searchParams.append('username', username);
|
||||||
|
url.searchParams.append('server_id', server_id);
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// если бэкенд вернул не-JSON, оставляем дефолтное сообщение
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchMe(): Promise<MeResponse> {
|
export async function fetchMe(): Promise<MeResponse> {
|
||||||
const { accessToken, clientToken } = getAuthTokens();
|
const { accessToken, clientToken } = getAuthTokens();
|
||||||
|
|
||||||
|
|||||||
254
src/renderer/components/CaseRoulette.tsx
Normal file
254
src/renderer/components/CaseRoulette.tsx
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
import { Box, Typography, Button, Dialog, DialogContent } from '@mui/material';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { CaseItem } from '../api';
|
||||||
|
|
||||||
|
type Rarity = 'common' | 'rare' | 'epic' | 'legendary';
|
||||||
|
|
||||||
|
interface CaseRouletteProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
caseName?: string;
|
||||||
|
items: CaseItem[];
|
||||||
|
reward: CaseItem | null; // что реально выпало с бэка
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- настройки рулетки ---
|
||||||
|
const ITEM_WIDTH = 110;
|
||||||
|
const ITEM_GAP = 8;
|
||||||
|
const VISIBLE_ITEMS = 21;
|
||||||
|
const CENTER_INDEX = Math.floor(VISIBLE_ITEMS / 2);
|
||||||
|
|
||||||
|
// ширина видимой области и позиция линии
|
||||||
|
const CONTAINER_WIDTH = 800;
|
||||||
|
const LINE_X = CONTAINER_WIDTH / 2;
|
||||||
|
|
||||||
|
// редкость по weight (только фронт)
|
||||||
|
function getRarityByWeight(weight?: number): Rarity {
|
||||||
|
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 CaseRoulette({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
caseName,
|
||||||
|
items,
|
||||||
|
reward,
|
||||||
|
}: CaseRouletteProps) {
|
||||||
|
const [sequence, setSequence] = useState<CaseItem[]>([]);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [animating, setAnimating] = useState(false);
|
||||||
|
|
||||||
|
const winningName =
|
||||||
|
reward?.meta?.display_name || reward?.name || reward?.material || '';
|
||||||
|
|
||||||
|
// генерируем полосу предметов и запускаем анимацию,
|
||||||
|
// когда диалог открыт и есть reward
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !reward || !items || items.length === 0) return;
|
||||||
|
|
||||||
|
// 1. генерим последовательность
|
||||||
|
const seq: CaseItem[] = [];
|
||||||
|
for (let i = 0; i < VISIBLE_ITEMS; i++) {
|
||||||
|
const randomItem = items[Math.floor(Math.random() * items.length)];
|
||||||
|
seq.push(randomItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. подменяем центр на тот предмет, который реально выпал
|
||||||
|
const fromCase =
|
||||||
|
items.find((i) => i.material === reward.material) || reward;
|
||||||
|
seq[CENTER_INDEX] = fromCase;
|
||||||
|
|
||||||
|
setSequence(seq);
|
||||||
|
|
||||||
|
// 3. считаем финальный offset, при котором CENTER_INDEX оказывается под линией
|
||||||
|
const centerItemCenter =
|
||||||
|
CENTER_INDEX * (ITEM_WIDTH + ITEM_GAP) + ITEM_WIDTH / 2;
|
||||||
|
const finalOffset = Math.max(0, centerItemCenter - LINE_X);
|
||||||
|
|
||||||
|
// стартуем анимацию
|
||||||
|
setAnimating(false);
|
||||||
|
setOffset(0);
|
||||||
|
|
||||||
|
// маленькая задержка, чтобы браузер применил начальное состояние
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
setAnimating(true);
|
||||||
|
setOffset(finalOffset);
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(id);
|
||||||
|
};
|
||||||
|
}, [open, reward, items]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
bgcolor: 'rgba(15, 15, 20, 0.95)',
|
||||||
|
borderRadius: '1vw',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
color="white"
|
||||||
|
sx={{ textAlign: 'center', mb: 2 }}
|
||||||
|
>
|
||||||
|
Открытие кейса {caseName}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: '1vw',
|
||||||
|
border: '2px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
px: 2,
|
||||||
|
py: 3,
|
||||||
|
width: `${CONTAINER_WIDTH}px`,
|
||||||
|
maxWidth: '100%',
|
||||||
|
mx: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Линия центра */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: `${LINE_X}px`,
|
||||||
|
transform: 'translateX(-1px)',
|
||||||
|
width: '2px',
|
||||||
|
bgcolor: 'rgba(255, 77, 77, 0.8)',
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Лента с предметами */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: `${ITEM_GAP}px`,
|
||||||
|
transform: `translateX(-${offset}px)`,
|
||||||
|
transition: animating
|
||||||
|
? 'transform 3s cubic-bezier(0.1, 0.8, 0.2, 1)'
|
||||||
|
: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sequence.map((item, index) => {
|
||||||
|
const color = getRarityColor(item.weight);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={index}
|
||||||
|
sx={{
|
||||||
|
width: `120px`,
|
||||||
|
height: '120px',
|
||||||
|
borderRadius: '0.7vw',
|
||||||
|
bgcolor: 'rgba(255, 255, 255, 0.04)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border:
|
||||||
|
index === CENTER_INDEX
|
||||||
|
? `2px solid ${color}`
|
||||||
|
: `1px solid ${color}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={`https://cdn.minecraft.popa-popa.ru/textures/${item.material.toLowerCase()}.png`}
|
||||||
|
alt={item.material}
|
||||||
|
sx={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
objectFit: 'contain',
|
||||||
|
imageRendering: 'pixelated',
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
color: 'white',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.meta?.display_name ||
|
||||||
|
item.name ||
|
||||||
|
item.material
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{winningName && (
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
color="white"
|
||||||
|
sx={{ textAlign: 'center', mt: 2 }}
|
||||||
|
>
|
||||||
|
Вам выпало: <b>{winningName}</b>
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
mt: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={onClose}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '20px',
|
||||||
|
p: '0.5vw 2.5vw',
|
||||||
|
color: 'white',
|
||||||
|
backgroundColor: 'rgb(255, 77, 77)',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'rgba(255, 77, 77, 0.5)',
|
||||||
|
},
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,16 @@
|
|||||||
import { Box } from '@mui/material';
|
import {
|
||||||
import { Typography } from '@mui/material';
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
|
CardMedia,
|
||||||
|
CardContent,
|
||||||
|
Snackbar,
|
||||||
|
Alert,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
} from '@mui/material';
|
||||||
import CapeCard from '../components/CapeCard';
|
import CapeCard from '../components/CapeCard';
|
||||||
import {
|
import {
|
||||||
Cape,
|
Cape,
|
||||||
@ -7,9 +18,43 @@ import {
|
|||||||
fetchCapesStore,
|
fetchCapesStore,
|
||||||
purchaseCape,
|
purchaseCape,
|
||||||
StoreCape,
|
StoreCape,
|
||||||
|
Case,
|
||||||
|
CaseItem,
|
||||||
|
fetchCases,
|
||||||
|
fetchCase,
|
||||||
|
openCase,
|
||||||
|
Server,
|
||||||
} 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 CaseRoulette from '../components/CaseRoulette';
|
||||||
|
|
||||||
|
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() {
|
export default function Shop() {
|
||||||
const [storeCapes, setStoreCapes] = useState<StoreCape[]>([]);
|
const [storeCapes, setStoreCapes] = useState<StoreCape[]>([]);
|
||||||
@ -18,6 +63,39 @@ export default function Shop() {
|
|||||||
const [uuid, setUuid] = useState<string>('');
|
const [uuid, setUuid] = useState<string>('');
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Кейсы
|
||||||
|
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 ITEM_WIDTH = 110; // ширина "карточки" предмета в рулетке
|
||||||
|
const ITEM_GAP = 8;
|
||||||
|
const VISIBLE_ITEMS = 21; // сколько элементов в линии
|
||||||
|
const CENTER_INDEX = Math.floor(VISIBLE_ITEMS / 2);
|
||||||
|
|
||||||
// Функция для загрузки плащей из магазина
|
// Функция для загрузки плащей из магазина
|
||||||
const loadStoreCapes = async () => {
|
const loadStoreCapes = async () => {
|
||||||
try {
|
try {
|
||||||
@ -44,12 +122,55 @@ export default function Shop() {
|
|||||||
try {
|
try {
|
||||||
await purchaseCape(username, cape_id);
|
await purchaseCape(username, cape_id);
|
||||||
await loadUserCapes(username);
|
await loadUserCapes(username);
|
||||||
|
setNotification({
|
||||||
|
open: true,
|
||||||
|
message: 'Плащ успешно куплен!',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при покупке плаща:', 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(() => {
|
useEffect(() => {
|
||||||
const savedConfig = localStorage.getItem('launcher_config');
|
const savedConfig = localStorage.getItem('launcher_config');
|
||||||
if (savedConfig) {
|
if (savedConfig) {
|
||||||
@ -60,22 +181,118 @@ export default function Shop() {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// Загружаем оба списка плащей
|
Promise.all([
|
||||||
Promise.all([loadStoreCapes(), loadUserCapes(config.username)]).finally(
|
loadStoreCapes(),
|
||||||
() => {
|
loadUserCapes(config.username),
|
||||||
|
loadCases(),
|
||||||
|
])
|
||||||
|
.catch((err) => console.error(err))
|
||||||
|
.finally(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Проверяем онлайн после того, как знаем username
|
||||||
|
useEffect(() => {
|
||||||
|
if (username) {
|
||||||
|
checkPlayerStatus();
|
||||||
|
}
|
||||||
|
}, [username]);
|
||||||
|
|
||||||
// Фильтруем плащи, которые уже куплены пользователем
|
// Фильтруем плащи, которые уже куплены пользователем
|
||||||
const availableCapes = storeCapes.filter(
|
const availableCapes = storeCapes.filter(
|
||||||
(storeCape) =>
|
(storeCape) =>
|
||||||
!userCapes.some((userCape) => userCape.cape_id === storeCape.id),
|
!userCapes.some((userCape) => userCape.cape_id === storeCape.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Генерация массива предметов для рулетки
|
||||||
|
const generateRouletteItems = (
|
||||||
|
allItems: Case['items'],
|
||||||
|
winningItemMaterial: string,
|
||||||
|
): Case['items'] => {
|
||||||
|
if (!allItems || allItems.length === 0) return [];
|
||||||
|
|
||||||
|
const result: Case['items'] = [];
|
||||||
|
for (let i = 0; i < VISIBLE_ITEMS; i++) {
|
||||||
|
const randomItem = allItems[Math.floor(Math.random() * allItems.length)];
|
||||||
|
result.push(randomItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Принудительно ставим выигрышный предмет в центр
|
||||||
|
const winningSource =
|
||||||
|
allItems.find((i) => i.material === winningItemMaterial) || allItems[0];
|
||||||
|
result[CENTER_INDEX] = winningSource;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@ -88,9 +305,11 @@ export default function Shop() {
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{(loading || onlineCheckLoading) && (
|
||||||
<FullScreenLoader message="Загрузка..." />
|
<FullScreenLoader message="Загрузка магазина..." />
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{!loading && !onlineCheckLoading && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -102,6 +321,106 @@ export default function Shop() {
|
|||||||
gap: '2vw',
|
gap: '2vw',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Блок кейсов */}
|
||||||
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||||
|
Кейсы
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{!isOnline && (
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
color="error"
|
||||||
|
sx={{ mb: 2, maxWidth: '600px' }}
|
||||||
|
>
|
||||||
|
Для открытия кейсов вам необходимо находиться на одном из серверов
|
||||||
|
игры. Зайдите в игру и обновите страницу.
|
||||||
|
</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}>
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
borderRadius: '1vw',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.image_url && (
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
image={c.image_url}
|
||||||
|
alt={c.name}
|
||||||
|
sx={{
|
||||||
|
minWidth: '10vw',
|
||||||
|
minHeight: '10vw',
|
||||||
|
maxHeight: '10vw',
|
||||||
|
objectFit: 'cover',
|
||||||
|
p: '0.5vw',
|
||||||
|
borderRadius: '1vw 1vw 0 0',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" color="white">
|
||||||
|
{c.name}
|
||||||
|
</Typography>
|
||||||
|
{c.description && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="white"
|
||||||
|
sx={{ opacity: 0.7 }}
|
||||||
|
>
|
||||||
|
{c.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography variant="body2" color="white" sx={{ mt: 1 }}>
|
||||||
|
Цена: {c.price} монет
|
||||||
|
</Typography>
|
||||||
|
{typeof c.items_count === 'number' && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="white"
|
||||||
|
sx={{ opacity: 0.7 }}
|
||||||
|
>
|
||||||
|
Предметов в кейсе: {c.items_count}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
mt: '1vw',
|
||||||
|
borderRadius: '20px',
|
||||||
|
p: '0.3vw 0vw',
|
||||||
|
color: 'white',
|
||||||
|
backgroundColor: 'rgb(255, 77, 77)',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'rgba(255, 77, 77, 0.5)',
|
||||||
|
},
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
fontSize: '1vw',
|
||||||
|
}}
|
||||||
|
disabled={!isOnline || isOpening}
|
||||||
|
onClick={() => handleOpenCase(c)}
|
||||||
|
>
|
||||||
|
{isOpening && selectedCase?.id === c.id
|
||||||
|
? 'Открываем...'
|
||||||
|
: 'Открыть кейс'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Typography>Кейсы временно недоступны.</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Блок плащей (как был) */}
|
||||||
<Typography variant="h6">Доступные плащи</Typography>
|
<Typography variant="h6">Доступные плащи</Typography>
|
||||||
{availableCapes.length > 0 ? (
|
{availableCapes.length > 0 ? (
|
||||||
<Box
|
<Box
|
||||||
@ -126,6 +445,30 @@ export default function Shop() {
|
|||||||
)}
|
)}
|
||||||
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user