390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
// 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',
|
||
fontSize: '1vw',
|
||
px: '3vw',
|
||
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"
|
||
icon={false}
|
||
sx={{
|
||
mb: 2,
|
||
borderRadius: '1.1vw',
|
||
px: '1.4vw',
|
||
py: '1.1vw',
|
||
color: 'rgba(255,255,255,0.90)',
|
||
fontWeight: 800,
|
||
bgcolor: 'rgba(255,255,255,0.04)',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
position: 'relative',
|
||
overflow: 'hidden',
|
||
backdropFilter: 'blur(10px)',
|
||
boxShadow: '0 1.0vw 2.8vw rgba(0,0,0,0.45)',
|
||
'& .MuiAlert-message': {
|
||
padding: 0,
|
||
width: '100%',
|
||
},
|
||
'&:before': {
|
||
content: '""',
|
||
position: 'absolute',
|
||
inset: 0,
|
||
background:
|
||
'radial-gradient(circle at 12% 30%, rgba(242,113,33,0.22), transparent 60%), radial-gradient(circle at 85% 0%, rgba(233,64,205,0.14), transparent 55%)',
|
||
pointerEvents: 'none',
|
||
},
|
||
'&:after': {
|
||
content: '""',
|
||
position: 'absolute',
|
||
left: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
width: '0.35vw',
|
||
background: 'linear-gradient(180deg, #F27121 0%, #E940CD 65%, #8A2387 100%)',
|
||
opacity: 0.95,
|
||
pointerEvents: 'none',
|
||
},
|
||
}}
|
||
>
|
||
<Typography sx={{ position: 'relative', zIndex: 1, color: 'rgba(255,255,255,0.90)', fontWeight: 800 }}>
|
||
Зайдите на сервер сегодня, чтобы открыть получение наград за квесты.
|
||
</Typography>
|
||
</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>
|
||
);
|
||
}
|