fix i cheto sdelal

This commit is contained in:
aurinex
2025-12-13 20:14:24 +05:00
parent d9a3a1cd1f
commit abb45c3838
7 changed files with 513 additions and 7 deletions

View File

@ -23,6 +23,7 @@ import { News } from './pages/News';
import PageHeader from './components/PageHeader';
import { useLocation } from 'react-router-dom';
import DailyReward from './pages/DailyReward';
import DailyQuests from './pages/DailyQuests';
const AuthCheck = ({ children }: { children: ReactNode }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
@ -182,6 +183,14 @@ const AppLayout = () => {
</AuthCheck>
}
/>
<Route
path="/dailyquests"
element={
<AuthCheck>
<DailyQuests />
</AuthCheck>
}
/>
<Route
path="/shop"
element={

View File

@ -248,6 +248,36 @@ export type BonusTypesResponse = {
bonuses: BonusType[];
};
export interface DailyQuest {
key: string;
title: string;
event?: string;
target?: string;
required: number;
progress: number;
reward: number;
status: 'active' | 'completed' | 'claimed';
claimed_at?: string;
completed_at?: string;
}
export interface DailyQuestsStatusResponse {
ok: boolean;
day: string;
was_online_today: boolean;
seconds_to_next: number;
next_reset_at_utc: string;
next_reset_at_local: string;
quests: DailyQuest[];
}
export interface DailyQuestClaimResponse {
claimed: boolean;
coins_added?: number;
reason?: string;
message?: string;
}
export async function fetchBonusTypes(): Promise<BonusType[]> {
const response = await fetch(`${API_BASE_URL}/api/bonuses/types`);
@ -575,6 +605,54 @@ export async function claimDaily(): Promise<DailyClaimResponse> {
// ===== Ежедневная награда ===== \\
// ===== Ежедневные квесты =====
export async function fetchDailyQuestsStatus(): Promise<DailyQuestsStatusResponse> {
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-quests/status?${params.toString()}`,
);
if (!response.ok) {
throw new Error('Не удалось получить статус ежедневных квестов');
}
return await response.json();
}
export async function claimDailyQuest(questKey: string): Promise<DailyQuestClaimResponse> {
const { accessToken, clientToken } = getAuthTokens();
if (!accessToken || !clientToken) {
throw new Error('Нет токенов авторизации');
}
const params = new URLSearchParams({ accessToken, clientToken, quest_key: questKey });
const response = await fetch(
`${API_BASE_URL}/users/daily-quests/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> {
const { accessToken, clientToken } = getAuthTokens();

View File

@ -46,6 +46,13 @@ export default function PageHeader() {
};
}
if (path.startsWith('/dailyquests')) {
return {
title: 'Ежедневные задания',
subtitle: 'Выполняйте ежедневные задания разной сложности и получайте награды!',
};
}
if (path.startsWith('/profile')) {
return {
title: 'Профиль пользователя',

View File

@ -0,0 +1,345 @@
// 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',
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" sx={{ mb: 2 }}>
Зайдите на сервер сегодня, чтобы открыть получение наград за квесты.
</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>
);
}

View File

@ -66,6 +66,10 @@ export default function Profile() {
navigate('/daily');
};
const navigateDailyQuests = () => {
navigate('/dailyquests');
};
useEffect(() => {
const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) {
@ -513,6 +517,22 @@ export default function Profile() {
>
Ежедневные награды
</Button>
<Button
variant="contained"
fullWidth
onClick={navigateDailyQuests}
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)' },
}}
>
Ежедневные квесты
</Button>
</Box>
</>
)}

View File

@ -26,7 +26,7 @@ import { getPlayerServer } from '../utils/playerOnlineCheck';
import CaseRoulette from '../components/CaseRoulette';
import BonusShopItem from '../components/BonusShopItem';
import ShopItem from '../components/ShopItem';
import { playBuySound } from '../utils/sounds';
import { playBuySound, primeSounds } from '../utils/sounds';
function getRarityByWeight(
weight?: number,
@ -376,6 +376,7 @@ export default function Shop() {
setRouletteCaseItems(caseItems);
setRouletteReward(result.reward);
setRouletteOpen(true);
playBuySound();
// 4. уведомление
setNotification({
@ -405,6 +406,13 @@ export default function Shop() {
setRouletteOpen(false);
};
useEffect(() => {
const onFirstUserGesture = () => primeSounds();
window.addEventListener('pointerdown', onFirstUserGesture, { once: true });
return () => window.removeEventListener('pointerdown', onFirstUserGesture);
}, []);
return (
<Box
sx={{

View File

@ -1,13 +1,52 @@
import buySound from '../assets/sounds/buy.mp3';
let buyAudio: HTMLAudioElement | null = null;
let unlocked = false;
export const primeSounds = () => {
try {
if (unlocked) return;
if (!buyAudio) {
buyAudio = new Audio(buySound);
buyAudio.volume = 0; // тихо, чтобы не слышно
}
// попытка "разлочить" аудио в контексте user gesture
const p = buyAudio.play();
if (p && typeof (p as Promise<void>).then === 'function') {
(p as Promise<void>)
.then(() => {
buyAudio?.pause();
if (buyAudio) buyAudio.currentTime = 0;
if (buyAudio) buyAudio.volume = 0.6; // вернуть норм громкость
unlocked = true;
})
.catch(() => {
// если заблокировано — попробуем снова при следующем клике
});
} else {
// на всякий: если play синхронный
buyAudio.pause();
buyAudio.currentTime = 0;
buyAudio.volume = 0.6;
unlocked = true;
}
} catch {
// не ломаем UI
}
};
export const playBuySound = () => {
if (!buyAudio) {
buyAudio = new Audio(buySound);
buyAudio.volume = 0.6;
}
try {
if (!buyAudio) {
buyAudio = new Audio(buySound);
buyAudio.volume = 0.6;
}
buyAudio.currentTime = 0;
buyAudio.play().catch(() => {});
buyAudio.currentTime = 0;
buyAudio.play().catch(() => {});
} catch {
// игнор
}
};