Files
popa-launcher/src/renderer/pages/DailyQuests.tsx

390 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
);
}