add daily reward
This commit is contained in:
@ -426,8 +426,6 @@ export interface OpenCaseResponse {
|
|||||||
reward: CaseItem;
|
reward: CaseItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== КЕЙСЫ =====
|
|
||||||
|
|
||||||
export async function fetchCases(): Promise<Case[]> {
|
export async function fetchCases(): Promise<Case[]> {
|
||||||
const response = await fetch(`${API_BASE_URL}/cases`);
|
const response = await fetch(`${API_BASE_URL}/cases`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -481,6 +479,72 @@ export async function openCase(
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== КЕЙСЫ ===== \\
|
||||||
|
|
||||||
|
// ===== Ежедневная награда =====
|
||||||
|
export interface DailyStatusResponse {
|
||||||
|
ok: boolean;
|
||||||
|
can_claim: boolean;
|
||||||
|
seconds_to_next: number;
|
||||||
|
was_online_today: boolean;
|
||||||
|
next_claim_at_utc: string;
|
||||||
|
next_claim_at_local: string;
|
||||||
|
streak: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyClaimResponse {
|
||||||
|
claimed: boolean;
|
||||||
|
coins_added?: number;
|
||||||
|
streak?: number;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDailyStatus(): Promise<DailyStatusResponse> {
|
||||||
|
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/status?${params.toString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Не удалось получить статус ежедневной награды');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function claimDaily(): Promise<DailyClaimResponse> {
|
||||||
|
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/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> {
|
export async function fetchMe(): Promise<MeResponse> {
|
||||||
const { accessToken, clientToken } = getAuthTokens();
|
const { accessToken, clientToken } = getAuthTokens();
|
||||||
|
|
||||||
|
|||||||
235
src/renderer/components/Profile/DailyRewards.tsx
Normal file
235
src/renderer/components/Profile/DailyRewards.tsx
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
LinearProgress,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { claimDaily, fetchDailyStatus, DailyStatusResponse } from '../../api';
|
||||||
|
import CoinsDisplay from '../CoinsDisplay';
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// ВАЖНО: синхронизируй с бэком. Сейчас у тебя в бэке: 10..50 :contentReference[oaicite:2]{index=2}
|
||||||
|
// Если хочешь 50..100 — поменяй здесь тоже.
|
||||||
|
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 DailyRewards({ onClaimed, onOpenGame }: Props) {
|
||||||
|
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 secondsLeft = status?.seconds_to_next ?? 0;
|
||||||
|
const streak = status?.streak ?? 0;
|
||||||
|
|
||||||
|
const wasOnlineToday = status?.was_online_today ?? false; // если бэк не прислал — считаем false
|
||||||
|
const canClaim = (status?.can_claim ?? false) && wasOnlineToday;
|
||||||
|
|
||||||
|
const nextClaimAt =
|
||||||
|
status?.next_claim_at_utc || status?.next_claim_at_local || '';
|
||||||
|
|
||||||
|
const todaysReward = useMemo(() => {
|
||||||
|
const effectiveStreak = canClaim
|
||||||
|
? Math.max(1, streak === 0 ? 1 : streak)
|
||||||
|
: streak;
|
||||||
|
return calcRewardByStreak(effectiveStreak);
|
||||||
|
}, [streak, canClaim]);
|
||||||
|
|
||||||
|
const progressValue = useMemo(() => {
|
||||||
|
const day = 24 * 3600;
|
||||||
|
const remaining = Math.min(day, Math.max(0, secondsLeft));
|
||||||
|
return ((day - remaining) / day) * 100;
|
||||||
|
}, [secondsLeft]);
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const s = (await fetchDailyStatus()) as DailyStatusCompat;
|
||||||
|
setStatus(s);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Ошибка загрузки статуса');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 - tick);
|
||||||
|
}, [status, tick, canClaim]);
|
||||||
|
|
||||||
|
const handleClaim = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
try {
|
||||||
|
const res = await claimDaily();
|
||||||
|
|
||||||
|
if (res.claimed) {
|
||||||
|
const added = res.coins_added ?? 0;
|
||||||
|
setSuccess(`Вы получили ${added} монет!`);
|
||||||
|
if (onClaimed) onClaimed(added);
|
||||||
|
} else {
|
||||||
|
// если бэк вернёт reason=not_online_today — покажем по-человечески
|
||||||
|
if (res.reason === 'not_online_today') {
|
||||||
|
setError(
|
||||||
|
'Чтобы забрать награду — зайдите на сервер сегодня хотя бы на минуту.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(res.reason || 'Награда недоступна');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadStatus();
|
||||||
|
setTick(0);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Ошибка при получении награды');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitle = useMemo(() => {
|
||||||
|
if (!status) return '';
|
||||||
|
if (!wasOnlineToday)
|
||||||
|
return 'Награда откроется после входа на сервер сегодня.';
|
||||||
|
if (canClaim) return 'Можно забрать прямо сейчас 🎁';
|
||||||
|
return `До следующей награды: ${formatHHMMSS(clientSecondsLeft)}`;
|
||||||
|
}, [status, wasOnlineToday, canClaim, clientSecondsLeft]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
width: '90%',
|
||||||
|
maxWidth: 520,
|
||||||
|
background: 'rgba(20,20,20,0.9)',
|
||||||
|
borderRadius: '2vw',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
boxShadow: '0 10px 40px rgba(0,0,0,0.8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ p: 3 }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
fontSize: '1.2rem',
|
||||||
|
mb: 1,
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(136deg, rgb(242,113,33), rgb(233,64,87), rgb(138,35,135))',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ежедневная награда
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<Alert severity="success" sx={{ mb: 2 }}>
|
||||||
|
{success}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!status ? (
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||||
|
Загружаем статус...
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}
|
||||||
|
>
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.75)' }}>
|
||||||
|
Серия дней: <b>{streak}</b>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: 'rgba(255,255,255,0.75)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Награда: <CoinsDisplay value={todaysReward} size="small" />
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
<LinearProgress variant="determinate" value={progressValue} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.85)', mb: 2 }}>
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{!wasOnlineToday && (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: 'rgba(255,255,255,0.6)',
|
||||||
|
mb: 2,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Зайдите на сервер сегодня — после этого кнопка станет активной.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
disabled={loading || !status.ok || !canClaim}
|
||||||
|
onClick={handleClaim}
|
||||||
|
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)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Забираем...' : 'Забрать награду'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -24,6 +24,7 @@ import {
|
|||||||
import CapeCard from '../components/CapeCard';
|
import CapeCard from '../components/CapeCard';
|
||||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||||
import { OnlinePlayersPanel } from '../components/OnlinePlayersPanel';
|
import { OnlinePlayersPanel } from '../components/OnlinePlayersPanel';
|
||||||
|
import DailyRewards from '../components/Profile/DailyRewards';
|
||||||
|
|
||||||
export default function Profile() {
|
export default function Profile() {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -193,6 +194,7 @@ export default function Profile() {
|
|||||||
gap: '100px',
|
gap: '100px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
overflowY: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@ -451,6 +453,7 @@ export default function Profile() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<OnlinePlayersPanel currentUsername={username} />
|
<OnlinePlayersPanel currentUsername={username} />
|
||||||
|
<DailyRewards />
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user