diff --git a/src/renderer/api.ts b/src/renderer/api.ts index 042ed70..70e6a87 100644 --- a/src/renderer/api.ts +++ b/src/renderer/api.ts @@ -426,8 +426,6 @@ export interface OpenCaseResponse { reward: CaseItem; } -// ===== КЕЙСЫ ===== - export async function fetchCases(): Promise { const response = await fetch(`${API_BASE_URL}/cases`); if (!response.ok) { @@ -481,6 +479,72 @@ export async function openCase( 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 { + 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 { + 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 { const { accessToken, clientToken } = getAuthTokens(); diff --git a/src/renderer/components/Profile/DailyRewards.tsx b/src/renderer/components/Profile/DailyRewards.tsx new file mode 100644 index 0000000..991cd85 --- /dev/null +++ b/src/renderer/components/Profile/DailyRewards.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [tick, setTick] = useState(0); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + 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 ( + + + + Ежедневная награда + + + {error && ( + + {error} + + )} + {success && ( + + {success} + + )} + + {!status ? ( + + Загружаем статус... + + ) : ( + <> + + + Серия дней: {streak} + + + + Награда: + + + + + + + + + {subtitle} + + + {!wasOnlineToday && ( + + Зайдите на сервер сегодня — после этого кнопка станет активной. + + )} + + + + )} + + + ); +} diff --git a/src/renderer/pages/Profile.tsx b/src/renderer/pages/Profile.tsx index 01a132c..34e56c8 100644 --- a/src/renderer/pages/Profile.tsx +++ b/src/renderer/pages/Profile.tsx @@ -24,6 +24,7 @@ import { import CapeCard from '../components/CapeCard'; import { FullScreenLoader } from '../components/FullScreenLoader'; import { OnlinePlayersPanel } from '../components/OnlinePlayersPanel'; +import DailyRewards from '../components/Profile/DailyRewards'; export default function Profile() { const fileInputRef = useRef(null); @@ -193,6 +194,7 @@ export default function Profile() { gap: '100px', width: '100%', justifyContent: 'center', + overflowY: 'auto', }} > {loading ? ( @@ -451,6 +453,7 @@ export default function Profile() { + )}