fix i cheto sdelal
This commit is contained in:
@ -23,6 +23,7 @@ import { News } from './pages/News';
|
||||
import PageHeader from './components/PageHeader';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import DailyReward from './pages/DailyReward';
|
||||
import DailyQuests from './pages/DailyQuests';
|
||||
|
||||
const AuthCheck = ({ children }: { children: ReactNode }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
@ -182,6 +183,14 @@ const AppLayout = () => {
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dailyquests"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<DailyQuests />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/shop"
|
||||
element={
|
||||
|
||||
@ -248,6 +248,36 @@ export type BonusTypesResponse = {
|
||||
bonuses: BonusType[];
|
||||
};
|
||||
|
||||
export interface DailyQuest {
|
||||
key: string;
|
||||
title: string;
|
||||
event?: string;
|
||||
target?: string;
|
||||
required: number;
|
||||
progress: number;
|
||||
reward: number;
|
||||
status: 'active' | 'completed' | 'claimed';
|
||||
claimed_at?: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
export interface DailyQuestsStatusResponse {
|
||||
ok: boolean;
|
||||
day: string;
|
||||
was_online_today: boolean;
|
||||
seconds_to_next: number;
|
||||
next_reset_at_utc: string;
|
||||
next_reset_at_local: string;
|
||||
quests: DailyQuest[];
|
||||
}
|
||||
|
||||
export interface DailyQuestClaimResponse {
|
||||
claimed: boolean;
|
||||
coins_added?: number;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function fetchBonusTypes(): Promise<BonusType[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/bonuses/types`);
|
||||
|
||||
@ -575,6 +605,54 @@ export async function claimDaily(): Promise<DailyClaimResponse> {
|
||||
|
||||
// ===== Ежедневная награда ===== \\
|
||||
|
||||
// ===== Ежедневные квесты =====
|
||||
|
||||
export async function fetchDailyQuestsStatus(): Promise<DailyQuestsStatusResponse> {
|
||||
const { accessToken, clientToken } = getAuthTokens();
|
||||
|
||||
if (!accessToken || !clientToken) {
|
||||
throw new Error('Нет токенов авторизации');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ accessToken, clientToken });
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/users/daily-quests/status?${params.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось получить статус ежедневных квестов');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function claimDailyQuest(questKey: string): Promise<DailyQuestClaimResponse> {
|
||||
const { accessToken, clientToken } = getAuthTokens();
|
||||
|
||||
if (!accessToken || !clientToken) {
|
||||
throw new Error('Нет токенов авторизации');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ accessToken, clientToken, quest_key: questKey });
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/users/daily-quests/claim?${params.toString()}`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let msg = 'Не удалось забрать награду за квест';
|
||||
try {
|
||||
const err = await response.json();
|
||||
msg = err.message || err.detail || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// ===== Ежедневные квесты ===== \\
|
||||
|
||||
export async function fetchMe(): Promise<MeResponse> {
|
||||
const { accessToken, clientToken } = getAuthTokens();
|
||||
|
||||
|
||||
@ -46,6 +46,13 @@ export default function PageHeader() {
|
||||
};
|
||||
}
|
||||
|
||||
if (path.startsWith('/dailyquests')) {
|
||||
return {
|
||||
title: 'Ежедневные задания',
|
||||
subtitle: 'Выполняйте ежедневные задания разной сложности и получайте награды!',
|
||||
};
|
||||
}
|
||||
|
||||
if (path.startsWith('/profile')) {
|
||||
return {
|
||||
title: 'Профиль пользователя',
|
||||
|
||||
345
src/renderer/pages/DailyQuests.tsx
Normal file
345
src/renderer/pages/DailyQuests.tsx
Normal file
@ -0,0 +1,345 @@
|
||||
// src/renderer/pages/DailyQuests.tsx
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Divider,
|
||||
LinearProgress,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import CoinsDisplay from '../components/CoinsDisplay';
|
||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||
import { claimDailyQuest, fetchDailyQuestsStatus, DailyQuestsStatusResponse } from '../api';
|
||||
|
||||
function formatHHMMSS(totalSeconds: number) {
|
||||
const s = Math.max(0, Math.floor(totalSeconds));
|
||||
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
|
||||
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
|
||||
const ss = String(s % 60).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
type Quest = {
|
||||
key: string;
|
||||
title: string;
|
||||
event?: string;
|
||||
target?: string;
|
||||
required: number;
|
||||
progress: number;
|
||||
reward: number;
|
||||
status: 'active' | 'completed' | 'claimed';
|
||||
claimed_at?: string;
|
||||
completed_at?: string;
|
||||
};
|
||||
|
||||
type DailyQuestsStatusCompat = DailyQuestsStatusResponse & {
|
||||
was_online_today?: boolean;
|
||||
seconds_to_next?: number;
|
||||
next_reset_at_utc?: string;
|
||||
next_reset_at_local?: string;
|
||||
quests?: Quest[];
|
||||
};
|
||||
|
||||
function statusChip(status: Quest['status']) {
|
||||
if (status === 'claimed')
|
||||
return <Chip size="small" label="Получено" sx={{ bgcolor: 'rgba(156,255,198,0.15)', color: 'rgba(156,255,198,0.95)', fontWeight: 800 }} />;
|
||||
if (status === 'completed')
|
||||
return <Chip size="small" label="Выполнено" sx={{ bgcolor: 'rgba(242,113,33,0.18)', color: 'rgba(242,113,33,0.95)', fontWeight: 800 }} />;
|
||||
return <Chip size="small" label="В процессе" sx={{ bgcolor: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.85)', fontWeight: 800 }} />;
|
||||
}
|
||||
|
||||
export default function DailyQuests() {
|
||||
const [status, setStatus] = useState<DailyQuestsStatusCompat | null>(null);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [actionLoadingKey, setActionLoadingKey] = useState<string>('');
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
const [error, setError] = useState<string>('');
|
||||
const [success, setSuccess] = useState<string>('');
|
||||
|
||||
const loadStatus = async () => {
|
||||
setError('');
|
||||
try {
|
||||
const s = (await fetchDailyQuestsStatus()) as DailyQuestsStatusCompat;
|
||||
setStatus(s);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Ошибка загрузки ежедневных заданий');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setPageLoading(true);
|
||||
await loadStatus();
|
||||
setPageLoading(false);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((x) => x + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const wasOnlineToday = status?.was_online_today ?? false;
|
||||
|
||||
const clientSecondsLeft = useMemo(() => {
|
||||
if (!status) return 0;
|
||||
return Math.max(0, (status.seconds_to_next ?? 0) - tick);
|
||||
}, [status, tick]);
|
||||
|
||||
const subtitle = useMemo(() => {
|
||||
if (!status) return '';
|
||||
if (!wasOnlineToday) return 'Награды откроются после входа на сервер сегодня.';
|
||||
return `До обновления заданий: ${formatHHMMSS(clientSecondsLeft)}`;
|
||||
}, [status, wasOnlineToday, clientSecondsLeft]);
|
||||
|
||||
const quests: Quest[] = useMemo(() => (status?.quests ?? []) as Quest[], [status]);
|
||||
const totalRewardLeft = useMemo(() => {
|
||||
// сколько ещё можно забрать сегодня (completed, но не claimed)
|
||||
return quests
|
||||
.filter((q) => q.status === 'completed')
|
||||
.reduce((sum, q) => sum + (q.reward ?? 0), 0);
|
||||
}, [quests]);
|
||||
|
||||
const handleClaim = async (questKey: string) => {
|
||||
setActionLoadingKey(questKey);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
const res = await claimDailyQuest(questKey);
|
||||
|
||||
if (res.claimed) {
|
||||
const added = res.coins_added ?? 0;
|
||||
setSuccess(`Вы получили ${added} монет!`);
|
||||
} else {
|
||||
if (res.reason === 'not_online_today') {
|
||||
setError('Чтобы забрать награду — зайдите на сервер сегодня хотя бы на минуту.');
|
||||
} else if (res.reason === 'not_completed') {
|
||||
setError('Сначала выполните задание, затем заберите награду.');
|
||||
} else if (res.reason === 'already_claimed') {
|
||||
setError('Награда уже получена.');
|
||||
} else {
|
||||
setError(res.message || res.reason || 'Награда недоступна');
|
||||
}
|
||||
}
|
||||
|
||||
await loadStatus();
|
||||
setTick(0);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Ошибка при получении награды');
|
||||
} finally {
|
||||
setActionLoadingKey('');
|
||||
}
|
||||
};
|
||||
|
||||
if (pageLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: '10vh' }}>
|
||||
<FullScreenLoader fullScreen={false} message="Загрузка ежедневных заданий..." />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '85vw', height: '100%', paddingBottom: '5vh' }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: '1.2vw',
|
||||
overflow: 'hidden',
|
||||
background:
|
||||
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.18), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.14), transparent 55%), rgba(10,10,20,0.94)',
|
||||
boxShadow: '0 1.2vw 3.8vw rgba(0,0,0,0.55)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: '76vh',
|
||||
}}
|
||||
>
|
||||
{/* sticky header */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 5,
|
||||
background:
|
||||
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.18), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.14), transparent 55%), rgba(10,10,20,0.94)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ px: '2vw', pt: '1.2vh' }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 1.5 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 1.5 }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
px: '2vw',
|
||||
pb: '1.5vw',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '2vw',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.6 }}>
|
||||
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.70)', fontWeight: 700 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" alignItems="center" spacing={1.2}>
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.75)', fontWeight: 800 }}>
|
||||
Можно забрать сегодня:
|
||||
</Typography>
|
||||
<CoinsDisplay value={totalRewardLeft} size="small" />
|
||||
<Button
|
||||
disableRipple
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: '2.5vw',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
borderColor: 'rgba(255,255,255,0.25)',
|
||||
color: '#fff',
|
||||
'&:hover': { borderColor: 'rgba(242,113,33,0.9)' },
|
||||
}}
|
||||
onClick={() => {
|
||||
setTick(0);
|
||||
loadStatus();
|
||||
}}
|
||||
>
|
||||
Обновить
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.10)' }} />
|
||||
</Box>
|
||||
|
||||
{/* content */}
|
||||
<Box sx={{ px: '2vw', py: '2vh', overflowY: 'auto', flex: 1 }}>
|
||||
{!wasOnlineToday && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
Зайдите на сервер сегодня, чтобы открыть получение наград за квесты.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{quests.length === 0 ? (
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.75)', mt: '6vh' }}>
|
||||
На сегодня заданий нет.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={1.6}>
|
||||
{quests.map((q) => {
|
||||
const req = Math.max(1, q.required ?? 1);
|
||||
const prog = Math.max(0, q.progress ?? 0);
|
||||
const pct = Math.min(100, (prog / req) * 100);
|
||||
|
||||
const canClaim = wasOnlineToday && q.status === 'completed';
|
||||
const disabled = !canClaim || actionLoadingKey === q.key;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
key={q.key}
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: '1.4vw',
|
||||
borderRadius: '1.1vw',
|
||||
bgcolor: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
color: '#fff',
|
||||
fontWeight: 900,
|
||||
fontSize: '1.25vw',
|
||||
lineHeight: 1.15,
|
||||
}}
|
||||
>
|
||||
{q.title}
|
||||
</Typography>
|
||||
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.65)', fontWeight: 700, mt: 0.6 }}>
|
||||
Прогресс: {Math.min(prog, req)}/{req}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
{statusChip(q.status)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CoinsDisplay value={q.reward ?? 0} size="small" />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={pct}
|
||||
sx={{
|
||||
height: '0.75vw',
|
||||
borderRadius: '999px',
|
||||
bgcolor: 'rgba(255,255,255,0.08)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
background:
|
||||
'linear-gradient(90deg, rgba(242,113,33,1) 0%, rgba(233,64,205,1) 55%, rgba(138,35,135,1) 100%)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
disableRipple
|
||||
variant="contained"
|
||||
disabled={disabled}
|
||||
onClick={() => handleClaim(q.key)}
|
||||
sx={{
|
||||
borderRadius: '2.5vw',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
background:
|
||||
canClaim
|
||||
? 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)'
|
||||
: 'rgba(255,255,255,0.10)',
|
||||
color: '#fff',
|
||||
'&:hover': {
|
||||
transform: canClaim ? 'scale(1.01)' : 'none',
|
||||
},
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{q.status === 'claimed'
|
||||
? 'Получено'
|
||||
: q.status === 'completed'
|
||||
? actionLoadingKey === q.key
|
||||
? 'Получаем...'
|
||||
: 'Забрать'
|
||||
: 'В процессе'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@ -66,6 +66,10 @@ export default function Profile() {
|
||||
navigate('/daily');
|
||||
};
|
||||
|
||||
const navigateDailyQuests = () => {
|
||||
navigate('/dailyquests');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const savedConfig = localStorage.getItem('launcher_config');
|
||||
if (savedConfig) {
|
||||
@ -513,6 +517,22 @@ export default function Profile() {
|
||||
>
|
||||
Ежедневные награды
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={navigateDailyQuests}
|
||||
sx={{
|
||||
mt: 1,
|
||||
transition: 'transform 0.3s ease',
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
borderRadius: '2.5vw',
|
||||
'&:hover': { transform: 'scale(1.03)' },
|
||||
}}
|
||||
>
|
||||
Ежедневные квесты
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -26,7 +26,7 @@ import { getPlayerServer } from '../utils/playerOnlineCheck';
|
||||
import CaseRoulette from '../components/CaseRoulette';
|
||||
import BonusShopItem from '../components/BonusShopItem';
|
||||
import ShopItem from '../components/ShopItem';
|
||||
import { playBuySound } from '../utils/sounds';
|
||||
import { playBuySound, primeSounds } from '../utils/sounds';
|
||||
|
||||
function getRarityByWeight(
|
||||
weight?: number,
|
||||
@ -376,6 +376,7 @@ export default function Shop() {
|
||||
setRouletteCaseItems(caseItems);
|
||||
setRouletteReward(result.reward);
|
||||
setRouletteOpen(true);
|
||||
playBuySound();
|
||||
|
||||
// 4. уведомление
|
||||
setNotification({
|
||||
@ -405,6 +406,13 @@ export default function Shop() {
|
||||
setRouletteOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onFirstUserGesture = () => primeSounds();
|
||||
|
||||
window.addEventListener('pointerdown', onFirstUserGesture, { once: true });
|
||||
return () => window.removeEventListener('pointerdown', onFirstUserGesture);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@ -1,8 +1,44 @@
|
||||
import buySound from '../assets/sounds/buy.mp3';
|
||||
|
||||
let buyAudio: HTMLAudioElement | null = null;
|
||||
let unlocked = false;
|
||||
|
||||
export const primeSounds = () => {
|
||||
try {
|
||||
if (unlocked) return;
|
||||
|
||||
if (!buyAudio) {
|
||||
buyAudio = new Audio(buySound);
|
||||
buyAudio.volume = 0; // тихо, чтобы не слышно
|
||||
}
|
||||
|
||||
// попытка "разлочить" аудио в контексте user gesture
|
||||
const p = buyAudio.play();
|
||||
if (p && typeof (p as Promise<void>).then === 'function') {
|
||||
(p as Promise<void>)
|
||||
.then(() => {
|
||||
buyAudio?.pause();
|
||||
if (buyAudio) buyAudio.currentTime = 0;
|
||||
if (buyAudio) buyAudio.volume = 0.6; // вернуть норм громкость
|
||||
unlocked = true;
|
||||
})
|
||||
.catch(() => {
|
||||
// если заблокировано — попробуем снова при следующем клике
|
||||
});
|
||||
} else {
|
||||
// на всякий: если play синхронный
|
||||
buyAudio.pause();
|
||||
buyAudio.currentTime = 0;
|
||||
buyAudio.volume = 0.6;
|
||||
unlocked = true;
|
||||
}
|
||||
} catch {
|
||||
// не ломаем UI
|
||||
}
|
||||
};
|
||||
|
||||
export const playBuySound = () => {
|
||||
try {
|
||||
if (!buyAudio) {
|
||||
buyAudio = new Audio(buySound);
|
||||
buyAudio.volume = 0.6;
|
||||
@ -10,4 +46,7 @@ export const playBuySound = () => {
|
||||
|
||||
buyAudio.currentTime = 0;
|
||||
buyAudio.play().catch(() => {});
|
||||
} catch {
|
||||
// игнор
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user