601 lines
19 KiB
TypeScript
601 lines
19 KiB
TypeScript
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>
|
||
);
|
||
}
|