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

601 lines
19 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.

import React, { useEffect, useMemo, useState } from 'react';
import {
Box,
Typography,
IconButton,
Stack,
Paper,
ButtonBase,
Divider,
LinearProgress,
Alert,
Button,
} from '@mui/material';
import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
import TodayRoundedIcon from '@mui/icons-material/TodayRounded';
import CustomTooltip from '../components/Notifications/CustomTooltip';
import CoinsDisplay from '../components/CoinsDisplay';
import {
claimDaily,
fetchDailyStatus,
DailyStatusResponse,
fetchDailyClaimDays,
} from '../api';
const pulseGradient = {
'@keyframes pulseGlow': {
'0%': {
opacity: 0.35,
transform: 'scale(0.9)',
},
'50%': {
opacity: 0.7,
transform: 'scale(1.05)',
},
'100%': {
opacity: 0.35,
transform: 'scale(0.9)',
},
},
};
const RU_MONTHS = [
'Январь',
'Февраль',
'Март',
'Апрель',
'Май',
'Июнь',
'Июль',
'Август',
'Сентябрь',
'Октябрь',
'Ноябрь',
'Декабрь',
];
const RU_WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const pad2 = (n: number) => String(n).padStart(2, '0');
const keyOf = (d: Date) =>
`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
const startOfDay = (d: Date) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate());
const isSameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
const weekdayMonFirst = (date: Date) => (date.getDay() + 6) % 7;
const EKATERINBURG_TZ = 'Asia/Yekaterinburg';
function keyOfInTZ(date: Date, timeZone: string) {
// en-CA даёт ровно YYYY-MM-DD
return new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
}
type Cell = { date: Date; inCurrentMonth: boolean };
function buildCalendarGrid(viewYear: number, viewMonth: number): Cell[] {
const first = new Date(viewYear, viewMonth, 1);
const lead = weekdayMonFirst(first);
const total = 42;
const cells: Cell[] = [];
for (let i = 0; i < total; i++) {
const d = new Date(viewYear, viewMonth, 1 - lead + i);
cells.push({ date: d, inCurrentMonth: d.getMonth() === viewMonth });
}
return cells;
}
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}`;
}
function calcRewardByStreak(streak: number) {
return Math.min(10 + Math.max(0, streak - 1) * 10, 50);
}
type Props = {
onClaimed?: (coinsAdded: number) => void;
onOpenGame?: () => void;
};
type DailyStatusCompat = DailyStatusResponse & {
was_online_today?: boolean;
next_claim_at_utc?: string;
next_claim_at_local?: string;
};
export default function DailyReward({ onClaimed }: Props) {
const today = useMemo(() => startOfDay(new Date()), []);
const [view, setView] = useState(
() => new Date(today.getFullYear(), today.getMonth(), 1),
);
const [selected, setSelected] = useState<Date>(today);
// перенесённая логика статуса/клейма
const [status, setStatus] = useState<DailyStatusCompat | null>(null);
const [loading, setLoading] = useState(false);
const [tick, setTick] = useState(0);
const [error, setError] = useState<string>('');
const [success, setSuccess] = useState<string>('');
const [claimDays, setClaimDays] = useState<Set<string>>(new Set());
const viewYear = view.getFullYear();
const viewMonth = view.getMonth();
const grid = useMemo(
() => buildCalendarGrid(viewYear, viewMonth),
[viewYear, viewMonth],
);
const streak = status?.streak ?? 0;
const wasOnlineToday = status?.was_online_today ?? false;
const canClaim = (status?.can_claim ?? false) && wasOnlineToday;
const goPrev = () =>
setView((v) => new Date(v.getFullYear(), v.getMonth() - 1, 1));
const goNext = () =>
setView((v) => new Date(v.getFullYear(), v.getMonth() + 1, 1));
const goToday = () => {
const t = new Date(today.getFullYear(), today.getMonth(), 1);
setView(t);
setSelected(today);
};
const selectedKey = keyOf(selected);
const loadStatus = async () => {
setError('');
try {
const s = (await fetchDailyStatus()) as DailyStatusCompat;
setStatus(s);
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка загрузки статуса');
}
};
const loadClaimDays = async () => {
try {
const r = await fetchDailyClaimDays(180);
if (r.ok) setClaimDays(new Set(r.days));
} catch (e) {
console.error('Ошибка загрузки дней наград:', e);
// можно setError(...) если хочешь показывать
}
};
useEffect(() => {
loadStatus();
loadClaimDays();
}, []);
useEffect(() => {
const id = setInterval(() => setTick((x) => x + 1), 1000);
return () => clearInterval(id);
}, []);
const clientSecondsLeft = useMemo(() => {
if (!status) return 0;
if (canClaim) return 0;
return Math.max(0, (status.seconds_to_next ?? 0) - tick);
}, [status, tick, canClaim]);
// ✅ фикс прогресса: считаем от clientSecondsLeft, а не от status.seconds_to_next (который не меняется)
const progressValue = useMemo(() => {
const day = 24 * 3600;
const remaining = Math.min(day, Math.max(0, clientSecondsLeft));
return ((day - remaining) / day) * 100;
}, [clientSecondsLeft]);
const todaysReward = useMemo(() => {
const effectiveStreak = canClaim
? Math.max(1, streak === 0 ? 1 : streak)
: streak;
return calcRewardByStreak(effectiveStreak);
}, [streak, canClaim]);
const subtitle = useMemo(() => {
if (!status) return '';
if (!wasOnlineToday)
return 'Награда откроется после входа на сервер сегодня.';
if (canClaim) return 'Можно забрать прямо сейчас 🎁';
return `До следующей награды: ${formatHHMMSS(clientSecondsLeft)}`;
}, [status, wasOnlineToday, canClaim, clientSecondsLeft]);
const handleClaim = async () => {
setLoading(true);
setError('');
setSuccess('');
try {
const res = await claimDaily();
if (res.claimed) {
const added = res.coins_added ?? 0;
setSuccess(`Вы получили ${added} монет!`);
onClaimed?.(added);
} else {
if (res.reason === 'not_online_today') {
setError(
'Чтобы забрать награду — зайдите на сервер сегодня хотя бы на минуту.',
);
} else {
setError(res.reason || 'Награда недоступна');
}
}
await loadStatus();
await loadClaimDays();
setTick(0);
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка при получении награды');
} finally {
setLoading(false);
}
};
return (
<Box
sx={{
// px: { xs: 2, md: 4 },
// py: { xs: 2, md: 3 },
mt: '-3vh',
width: '85%',
overflowY: 'auto',
paddingTop: '5vh',
paddingBottom: '5vh',
}}
>
<Paper
elevation={0}
sx={{
borderRadius: 4,
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 18px 60px rgba(0,0,0,0.55)',
}}
>
{/* alerts */}
<Box sx={{ px: { xs: 2, md: 3 }, pt: 2 }}>
{error && (
<Alert severity="error" sx={{ mb: 1.5 }}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 1.5 }}>
{success}
</Alert>
)}
</Box>
{/* Header */}
<Box
sx={{
px: { xs: 2, md: 3 },
pb: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}
>
<Box
sx={{
minWidth: 220,
display: 'flex',
gap: '1vw',
alignItems: 'center',
}}
>
<Typography
sx={{ color: 'rgba(255,255,255,0.75)', display: 'flex', gap: 1 }}
>
<CoinsDisplay value={todaysReward} size="small" />
</Typography>
<Typography
sx={{
fontFamily:
'Benzin-Bold, system-ui, -apple-system, Segoe UI, Roboto, Arial',
fontWeight: 800,
fontSize: '1rem',
color: '#fff',
lineHeight: 1.15,
textTransform: 'uppercase',
}}
>
Серия дней: <b>{streak}</b>
</Typography>
</Box>
<Stack direction="row" alignItems="center" spacing={1}>
<CustomTooltip title="К текущему месяцу">
<IconButton
onClick={goToday}
sx={{
color: 'rgba(255,255,255,0.9)',
bgcolor: 'rgba(0,0,0,0.22)',
'&:hover': { bgcolor: 'rgba(255,255,255,0.08)' },
}}
>
<TodayRoundedIcon sx={{ fontSize: '2vw', p: '0.2vw' }} />
</IconButton>
</CustomTooltip>
<IconButton
onClick={goPrev}
sx={{
color: 'rgba(255,255,255,0.9)',
bgcolor: 'rgba(0,0,0,0.22)',
'&:hover': { bgcolor: 'rgba(255,255,255,0.08)' },
}}
>
<ChevronLeftRoundedIcon />
</IconButton>
<Box sx={{ minWidth: 160, textAlign: 'center', maxWidth: '10vw' }}>
<Typography
sx={{
color: '#fff',
fontWeight: 800,
letterSpacing: 0.2,
fontSize: { xs: 14.5, md: 16 },
}}
>
{RU_MONTHS[viewMonth]} {viewYear}
</Typography>
</Box>
<IconButton
onClick={goNext}
sx={{
color: 'rgba(255,255,255,0.9)',
bgcolor: 'rgba(0,0,0,0.22)',
'&:hover': { bgcolor: 'rgba(255,255,255,0.08)' },
}}
>
<ChevronRightRoundedIcon />
</IconButton>
</Stack>
</Box>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.10)' }} />
{/* Calendar */}
<Box sx={{ px: { xs: 2, md: 3 }, py: 2.5 }}>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 1,
mb: 1.2,
}}
>
{RU_WEEKDAYS.map((w, i) => (
<Typography
key={w}
sx={{
textAlign: 'center',
fontSize: 12,
fontWeight: 700,
color:
i >= 5 ? 'rgba(255,255,255,0.75)' : 'rgba(255,255,255,0.6)',
userSelect: 'none',
}}
>
{w}
</Typography>
))}
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 1,
}}
>
{grid.map(({ date, inCurrentMonth }) => {
const d = startOfDay(date);
const isToday = isSameDay(d, today);
const isSelected = isSameDay(d, selected);
const dayKeyEkb = keyOfInTZ(d, EKATERINBURG_TZ);
const claimed = claimDays.has(dayKeyEkb);
return (
<ButtonBase
key={dayKeyEkb}
onClick={() => setSelected(d)}
sx={{
width: '100%',
aspectRatio: '1 / 1',
borderRadius: 3,
position: 'relative',
overflow: 'hidden',
border: isSelected
? '1px solid rgba(242,113,33,0.85)'
: 'none',
bgcolor: inCurrentMonth
? 'rgba(0,0,0,0.24)'
: 'rgba(0,0,0,0.12)',
transition:
'transform 0.18s ease, background-color 0.18s ease, border-color 0.18s ease',
transform: isSelected ? 'scale(1.02)' : 'scale(1)',
'&:hover': {
bgcolor: inCurrentMonth
? 'rgba(255,255,255,0.06)'
: 'rgba(255,255,255,0.04)',
transform: 'translateY(-1px)',
},
}}
>
{isToday && (
<Box
sx={{
...pulseGradient,
position: 'absolute',
inset: -20,
background:
'radial-gradient(circle at 50% 50%, rgba(233,64,205,0.35), transparent 55%)',
pointerEvents: 'none',
animation: 'pulseGlow 2.6s ease-in-out infinite',
willChange: 'transform, opacity',
}}
/>
)}
<Box
sx={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 0.3,
position: 'relative',
}}
>
<Typography
sx={{
fontSize: 14,
fontWeight: 800,
color: inCurrentMonth
? '#fff'
: 'rgba(255,255,255,0.35)',
lineHeight: 1,
}}
>
{d.getDate()}
</Typography>
<Typography
sx={{
fontSize: 10.5,
color: claimed
? 'rgba(156, 255, 198, 0.9)'
: isToday
? 'rgba(242,113,33,0.95)'
: 'rgba(255,255,255,0.45)',
fontWeight: 700,
userSelect: 'none',
}}
>
{claimed ? 'получено' : isToday ? 'сегодня' : ''}
</Typography>
{claimed && (
<Box
sx={{
position: 'absolute',
bottom: 8,
width: 6,
height: 6,
borderRadius: 999,
bgcolor: 'rgba(156, 255, 198, 0.95)',
boxShadow: '0 0 12px rgba(156, 255, 198, 0.35)',
}}
/>
)}
</Box>
</ButtonBase>
);
})}
</Box>
{/* Footer actions */}
<Box
sx={{
mt: 2.2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
flexWrap: 'wrap',
}}
>
<Box>
<Typography
sx={{ color: 'rgba(255,255,255,0.65)', fontSize: 12 }}
>
Выбрано:{' '}
<span style={{ color: '#fff', fontWeight: 800 }}>
{selectedKey}
</span>
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: '1.2vw', alignItems: 'center' }}>
<CustomTooltip title={subtitle} disableInteractive>
<Box
sx={{
display: 'inline-block',
cursor:
loading || !status?.ok || !canClaim ? 'help' : 'pointer',
}}
>
<Button
variant="contained"
disabled={loading || !status?.ok || !canClaim}
onClick={handleClaim}
sx={{
px: 3,
py: 1.2,
borderRadius: '2vw',
textTransform: 'uppercase',
fontFamily:
'Benzin-Bold, system-ui, -apple-system, Segoe UI, Roboto, Arial',
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
transition:
'transform 0.25s ease, box-shadow 0.25s ease, filter 0.25s ease',
'&:hover': {
transform: 'scale(0.98)',
filter: 'brightness(0.92)',
boxShadow: '0 0.5vw 1vw rgba(0, 0, 0, 0.3)',
},
'&.Mui-disabled': {
background: 'rgba(255,255,255,0.10)',
color: 'rgba(255,255,255,0.45)',
pointerEvents: 'none', // важно оставить
},
}}
>
{loading ? 'Забираем...' : 'Забрать'}
</Button>
</Box>
</CustomTooltip>
<CustomTooltip title="Сбросить выбор на сегодня">
<IconButton
onClick={() => setSelected(today)}
sx={{
color: 'rgba(255,255,255,0.9)',
bgcolor: 'rgba(0,0,0,0.22)',
'&:hover': { bgcolor: 'rgba(255,255,255,0.08)' },
}}
>
<TodayRoundedIcon fontSize="small" />
</IconButton>
</CustomTooltip>
</Box>
</Box>
</Box>
</Paper>
</Box>
);
}