axyenniu daily reward page

This commit is contained in:
aurinex
2025-12-13 16:18:47 +05:00
parent 226f5c1393
commit 712ae70e2a
13 changed files with 835 additions and 49 deletions

View File

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

View File

@ -499,6 +499,36 @@ export interface DailyClaimResponse {
reason?: string; reason?: string;
} }
export interface DailyDaysResponse {
ok: boolean;
days: string[]; // YYYY-MM-DD (по ЕКБ)
count: number;
}
export async function fetchDailyClaimDays(limit = 120): Promise<DailyDaysResponse> {
const { accessToken, clientToken } = getAuthTokens();
if (!accessToken || !clientToken) {
throw new Error('Нет токенов авторизации');
}
const params = new URLSearchParams({
accessToken,
clientToken,
limit: String(limit),
});
const response = await fetch(
`${API_BASE_URL}/users/daily/days?${params.toString()}`,
);
if (!response.ok) {
throw new Error('Не удалось получить дни ежедневных наград');
}
return await response.json();
}
export async function fetchDailyStatus(): Promise<DailyStatusResponse> { export async function fetchDailyStatus(): Promise<DailyStatusResponse> {
const { accessToken, clientToken } = getAuthTokens(); const { accessToken, clientToken } = getAuthTokens();

View File

@ -11,7 +11,7 @@ import {
Box, Box,
Chip, Chip,
} from '@mui/material'; } from '@mui/material';
import CustomTooltip from './CustomTooltip'; import CustomTooltip from './Notifications/CustomTooltip';
// Тип для плаща с необязательными полями для обоих вариантов использования // Тип для плаща с необязательными полями для обоих вариантов использования
export interface CapeCardProps { export interface CapeCardProps {
cape: { cape: {

View File

@ -1,6 +1,6 @@
// CoinsDisplay.tsx // CoinsDisplay.tsx
import { Box, Typography } from '@mui/material'; import { Box, Typography } from '@mui/material';
import CustomTooltip from './CustomTooltip'; import CustomTooltip from './Notifications/CustomTooltip';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { fetchCoins } from '../api'; import { fetchCoins } from '../api';

View File

@ -0,0 +1,180 @@
import * as React from 'react';
import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
export type NotificationVertical = 'top' | 'bottom';
export type NotificationHorizontal = 'left' | 'center' | 'right';
export type NotificationPosition = {
vertical: NotificationVertical;
horizontal: NotificationHorizontal;
};
export type NotificationSeverity = 'success' | 'info' | 'warning' | 'error';
export interface CustomNotificationProps {
open: boolean;
message: React.ReactNode;
onClose: () => void;
severity?: NotificationSeverity;
position?: NotificationPosition;
autoHideDuration?: number;
variant?: 'filled' | 'outlined' | 'standard';
}
const getAccent = (severity: NotificationSeverity) => {
switch (severity) {
case 'success':
return {
// glow: 'rgba(43, 255, 0, 0.45)',
// a1: 'rgba(43, 255, 0, 0.90)',
// a2: 'rgba(0, 255, 170, 0.55)',
// a3: 'rgba(0, 200, 120, 0.35)',
glow: 'rgba(138, 35, 135, 0.45)',
a1: 'rgba(242, 113, 33, 0.90)',
a2: 'rgba(138, 35, 135, 0.90)',
a3: 'rgba(233, 64, 205, 0.90)',
};
case 'warning':
return {
glow: 'rgba(255, 193, 7, 0.45)',
a1: 'rgba(255, 193, 7, 0.90)',
a2: 'rgba(255, 120, 0, 0.55)',
a3: 'rgba(255, 80, 0, 0.35)',
};
case 'error':
return {
glow: 'rgba(255, 77, 77, 0.50)',
a1: 'rgba(255, 77, 77, 0.90)',
a2: 'rgba(233, 64, 87, 0.65)',
a3: 'rgba(138, 35, 135, 0.45)',
};
case 'info':
default:
return {
glow: 'rgba(33, 150, 243, 0.45)',
a1: 'rgba(33, 150, 243, 0.90)',
a2: 'rgba(0, 255, 255, 0.45)',
a3: 'rgba(120, 60, 255, 0.35)',
};
}
};
export default function CustomNotification({
open,
message,
onClose,
severity = 'info',
position = { vertical: 'bottom', horizontal: 'center' },
autoHideDuration = 3000,
variant = 'filled',
}: CustomNotificationProps) {
const accent = getAccent(severity);
const handleClose = (
_event?: React.SyntheticEvent | Event,
reason?: SnackbarCloseReason
) => {
if (reason === 'clickaway') return;
onClose();
};
return (
<Snackbar
open={open}
onClose={handleClose}
autoHideDuration={autoHideDuration}
anchorOrigin={position}
sx={{
'& .MuiSnackbarContent-root': {
background: 'transparent',
boxShadow: 'none',
},
}}
>
<Alert
onClose={handleClose}
severity={severity}
variant={variant}
icon={false}
sx={{
width: '100%',
borderRadius: '1vw',
px: '2vw',
py: '1vw',
display: 'flex',
alignItems: 'center',
// базовый фон как в тултипе
backgroundColor: 'rgba(0, 0, 0, 0.88)',
color: '#fff',
fontFamily: 'Benzin-Bold, sans-serif',
// рамка + неоновая подсветка
border: `1px solid ${accent.a2}`,
boxShadow: `
0 0 1.6vw ${accent.glow},
0 0 0.6vw rgba(0, 0, 0, 0.35),
inset 0 0 0.6vw rgba(0, 0, 0, 0.45)
`,
position: 'relative',
overflow: 'hidden',
// внутренний градиентный бордер как у CustomTooltip
'&::before': {
content: '""',
position: 'absolute',
inset: 0,
borderRadius: '1vw',
padding: '2px',
background: `
linear-gradient(
135deg,
${accent.a1} 0%,
${accent.a2} 50%,
${accent.a3} 100%
)
`,
WebkitMask:
'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
WebkitMaskComposite: 'xor',
maskComposite: 'exclude',
pointerEvents: 'none',
zIndex: 0,
},
// контент поверх ::before
'& .MuiAlert-message': {
position: 'relative',
zIndex: 1,
padding: 0,
fontSize: '1.5vw',
lineHeight: 1.25,
},
// кнопка закрытия
'& .MuiAlert-action': {
position: 'relative',
zIndex: 1,
alignItems: 'center',
padding: 0,
marginLeft: '1vw',
},
'& .MuiIconButton-root': {
color: 'rgba(255,255,255,0.85)',
transition: 'all 0.2s ease',
'&:hover': {
color: accent.a1,
transform: 'scale(1.08)',
backgroundColor: 'rgba(255,255,255,0.06)',
},
},
}}
>
{message}
</Alert>
</Snackbar>
);
}

View File

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

View File

@ -10,6 +10,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import { claimDaily, fetchDailyStatus, DailyStatusResponse } from '../../api'; import { claimDaily, fetchDailyStatus, DailyStatusResponse } from '../../api';
import CoinsDisplay from '../CoinsDisplay'; import CoinsDisplay from '../CoinsDisplay';
import { useNavigate } from 'react-router-dom';
function formatHHMMSS(totalSeconds: number) { function formatHHMMSS(totalSeconds: number) {
const s = Math.max(0, Math.floor(totalSeconds)); const s = Math.max(0, Math.floor(totalSeconds));
@ -37,6 +38,7 @@ type DailyStatusCompat = DailyStatusResponse & {
}; };
export default function DailyRewards({ onClaimed, onOpenGame }: Props) { export default function DailyRewards({ onClaimed, onOpenGame }: Props) {
const navigate = useNavigate();
const [status, setStatus] = useState<DailyStatusCompat | null>(null); const [status, setStatus] = useState<DailyStatusCompat | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
@ -129,11 +131,14 @@ export default function DailyRewards({ onClaimed, onOpenGame }: Props) {
return `До следующей награды: ${formatHHMMSS(clientSecondsLeft)}`; return `До следующей награды: ${formatHHMMSS(clientSecondsLeft)}`;
}, [status, wasOnlineToday, canClaim, clientSecondsLeft]); }, [status, wasOnlineToday, canClaim, clientSecondsLeft]);
const navigateDaily = () => {
navigate('/daily');
};
return ( return (
<Card <Card
sx={{ sx={{
width: '90%', width: '100%',
maxWidth: 520,
background: 'rgba(20,20,20,0.9)', background: 'rgba(20,20,20,0.9)',
borderRadius: '2vw', borderRadius: '2vw',
border: '1px solid rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.08)',
@ -227,6 +232,22 @@ export default function DailyRewards({ onClaimed, onOpenGame }: Props) {
> >
{loading ? 'Забираем...' : 'Забрать награду'} {loading ? 'Забираем...' : 'Забрать награду'}
</Button> </Button>
<Button
variant="contained"
fullWidth
onClick={navigateDaily}
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>
</> </>
)} )}
</CardContent> </CardContent>

View File

@ -5,7 +5,7 @@ import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Tooltip } from '@mui/material'; import { Tooltip } from '@mui/material';
import { fetchCoins } from '../api'; import { fetchCoins } from '../api';
import CustomTooltip from './CustomTooltip'; import CustomTooltip from './Notifications/CustomTooltip';
import CoinsDisplay from './CoinsDisplay'; import CoinsDisplay from './CoinsDisplay';
declare global { declare global {
interface Window { interface Window {

View File

@ -0,0 +1,483 @@
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 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%' }}>
<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={{
position: 'absolute',
inset: -20,
background: 'radial-gradient(circle at 50% 50%, rgba(233,64,205,0.22), transparent 55%)',
pointerEvents: 'none',
}}
/>
)}
<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>
);
}

View File

@ -169,7 +169,7 @@ export const News = () => {
borderRadius: '1.5vw', borderRadius: '1.5vw',
background: background:
'linear-gradient(145deg, rgba(255,255,255,0.06), rgba(0,0,0,0.85))', 'linear-gradient(145deg, rgba(255,255,255,0.06), rgba(0,0,0,0.85))',
border: '1px solid rgba(255, 255, 255, 0.15)', // border: '1px solid rgba(255, 255, 255, 0.15)',
boxShadow: '0 18px 45px rgba(0, 0, 0, 0.7)', boxShadow: '0 18px 45px rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(14px)', backdropFilter: 'blur(14px)',
}} }}
@ -350,7 +350,7 @@ export const News = () => {
borderRadius: '1.5vw', borderRadius: '1.5vw',
background: background:
'linear-gradient(145deg, rgba(255,255,255,0.06), rgba(0,0,0,0.85))', 'linear-gradient(145deg, rgba(255,255,255,0.06), rgba(0,0,0,0.85))',
border: '1px solid rgba(255, 255, 255, 0.08)', border: '1px solid rgba(255, 255, 255, 0)',
boxShadow: '0 18px 45px rgba(0, 0, 0, 0.7)', boxShadow: '0 18px 45px rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(14px)', backdropFilter: 'blur(14px)',
width: '80vw', width: '80vw',
@ -415,7 +415,7 @@ export const News = () => {
fontSize: '0.7vw', fontSize: '0.7vw',
color: 'rgba(255,255,255,0.85)', color: 'rgba(255,255,255,0.85)',
borderRadius: '999px', borderRadius: '999px',
border: '1px solid rgba(242,113,33,0.6)', // border: '1px solid rgba(242,113,33,0.6)',
background: background:
'linear-gradient(120deg, rgba(242,113,33,0.18), rgba(233,64,87,0.12), rgba(138,35,135,0.16))', 'linear-gradient(120deg, rgba(242,113,33,0.18), rgba(233,64,87,0.12), rgba(138,35,135,0.16))',
backdropFilter: 'blur(12px)', backdropFilter: 'blur(12px)',

View File

@ -25,8 +25,12 @@ 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'; import DailyRewards from '../components/Profile/DailyRewards';
import CustomNotification from '../components/Notifications/CustomNotification';
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
import { useNavigate } from 'react-router-dom';
export default function Profile() { export default function Profile() {
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [walkingSpeed, setWalkingSpeed] = useState<number>(0.5); const [walkingSpeed, setWalkingSpeed] = useState<number>(0.5);
const [skin, setSkin] = useState<string>(''); const [skin, setSkin] = useState<string>('');
@ -46,6 +50,22 @@ export default function Profile() {
const [viewerWidth, setViewerWidth] = useState(500); const [viewerWidth, setViewerWidth] = useState(500);
const [viewerHeight, setViewerHeight] = useState(600); const [viewerHeight, setViewerHeight] = useState(600);
// notification
const [notifOpen, setNotifOpen] = useState(false);
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
const [notifSeverity, setNotifSeverity] = useState<
'success' | 'info' | 'warning' | 'error'
>('success');
const [notifPos, setNotifPos] = useState<NotificationPosition>({
vertical: 'top',
horizontal: 'right',
});
const navigateDaily = () => {
navigate('/daily');
};
useEffect(() => { useEffect(() => {
const savedConfig = localStorage.getItem('launcher_config'); const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) { if (savedConfig) {
@ -154,8 +174,16 @@ export default function Profile() {
const handleUploadSkin = async () => { const handleUploadSkin = async () => {
setLoading(true); setLoading(true);
if (!skinFile || !username) { if (!skinFile || !username) {
setStatusMessage('Необходимо выбрать файл и указать имя пользователя'); const msg = 'Необходимо выбрать файл и указать имя пользователя';
setStatusMessage(msg);
setUploadStatus('error'); setUploadStatus('error');
// notification
setNotifMsg(msg);
setNotifSeverity('error');
setNotifOpen(true);
setLoading(false);
return; return;
} }
@ -163,10 +191,15 @@ export default function Profile() {
try { try {
await uploadSkin(username, skinFile, skinModel); await uploadSkin(username, skinFile, skinModel);
setStatusMessage('Скин успешно загружен!'); setStatusMessage('Скин успешно загружен!');
setUploadStatus('success'); setUploadStatus('success');
// notification
setNotifMsg('Скин успешно загружен!');
setNotifSeverity('success');
setNotifPos({ vertical: 'bottom', horizontal: 'left' });
setNotifOpen(true);
// Обновляем информацию о игроке, чтобы увидеть новый скин // Обновляем информацию о игроке, чтобы увидеть новый скин
const config = JSON.parse( const config = JSON.parse(
localStorage.getItem('launcher_config') || '{}', localStorage.getItem('launcher_config') || '{}',
@ -175,10 +208,18 @@ export default function Profile() {
loadPlayerData(config.uuid); loadPlayerData(config.uuid);
} }
} catch (error) { } catch (error) {
setStatusMessage( const msg = `Ошибка: ${
`Ошибка: ${error instanceof Error ? error.message : 'Не удалось загрузить скин'}`, error instanceof Error ? error.message : 'Не удалось загрузить скин'
); }`;
setStatusMessage(msg);
setUploadStatus('error'); setUploadStatus('error');
// notification
setNotifMsg(msg);
setNotifSeverity('error');
setNotifPos({ vertical: 'bottom', horizontal: 'left' });
setNotifOpen(true);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -187,16 +228,25 @@ export default function Profile() {
return ( return (
<Box <Box
sx={{ sx={{
my: 4, mt: '10vh',
display: 'flex', display: 'grid',
flexDirection: 'row', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
alignItems: 'center', gap: '3vw',
gap: '100px',
width: '100%', width: '100%',
height: '100%',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'start',
overflowY: 'auto', overflowY: 'auto',
}} }}
> >
<CustomNotification
open={notifOpen}
message={notifMsg}
severity={notifSeverity}
position={notifPos}
onClose={() => setNotifOpen(false)}
autoHideDuration={2500}
/>
{loading ? ( {loading ? (
<FullScreenLoader message="Загрузка вашего профиля" /> <FullScreenLoader message="Загрузка вашего профиля" />
) : ( ) : (
@ -208,6 +258,9 @@ export default function Profile() {
borderRadius: 2, borderRadius: 2,
overflow: 'hidden', overflow: 'hidden',
bgcolor: 'transparent', bgcolor: 'transparent',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}} }}
> >
{/* Используем переработанный компонент SkinViewer */} {/* Используем переработанный компонент SkinViewer */}
@ -243,16 +296,20 @@ export default function Profile() {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '1vw', gap: '1vw',
width: '100%',
maxWidth: '44vw',
justifySelf: 'start'
}} }}
> >
<Box <Box
sx={{ sx={{
width: '40vw', width: '100%',
maxWidth: '40vw',
bgcolor: 'rgba(255, 255, 255, 0.05)', bgcolor: 'rgba(255, 255, 255, 0.05)',
padding: '3vw', padding: '3vw',
borderRadius: '1vw', borderRadius: '1vw',
flexShrink: 0, flexShrink: 0,
boxSizing: 'border-box',
minWidth: 0,
}} }}
> >
<Box <Box
@ -379,19 +436,6 @@ export default function Profile() {
<MenuItem value="classic">Классическая (Steve)</MenuItem> <MenuItem value="classic">Классическая (Steve)</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
{uploadStatus === 'error' && (
<Alert severity="error" sx={{ mb: 2 }}>
{statusMessage}
</Alert>
)}
{uploadStatus === 'success' && (
<Alert severity="success" sx={{ mb: 2 }}>
{statusMessage}
</Alert>
)}
<Button <Button
sx={{ sx={{
color: 'white', color: 'white',
@ -453,7 +497,22 @@ export default function Profile() {
</Box> </Box>
</Box> </Box>
<OnlinePlayersPanel currentUsername={username} /> <OnlinePlayersPanel currentUsername={username} />
<DailyRewards /> <Button
variant="contained"
fullWidth
onClick={navigateDaily}
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> </Box>
</> </>
)} )}

View File

@ -52,7 +52,6 @@ export const VersionCard: React.FC<VersionCardProps> = ({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
borderRadius: '2.5vw', borderRadius: '2.5vw',
border: '1px solid rgba(255,255,255,0.06)',
boxShadow: isHovered boxShadow: isHovered
? '0 0 10px rgba(233,64,205,0.55)' ? '0 0 10px rgba(233,64,205,0.55)'
: '0 14px 40px rgba(0, 0, 0, 0.6)', : '0 14px 40px rgba(0, 0, 0, 0.6)',
@ -249,23 +248,22 @@ export const VersionsExplorer = () => {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
borderRadius: '2.5vw', borderRadius: '2.5vw',
border: '1px dashed rgba(255,255,255,0.3)', position: 'relative',
border: 'none',
boxShadow: '0 14px 40px rgba(0, 0, 0, 0.6)', boxShadow: '0 14px 40px rgba(0, 0, 0, 0.6)',
transition: 'transform 0.35s ease, box-shadow 0.35s ease',
overflow: 'hidden', overflow: 'hidden',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
cursor: 'pointer', cursor: 'pointer',
transform: hoveredCardId === 'add' ? 'scale(1.04)' : 'scale(1)', transition: 'transform 0.35s ease, box-shadow 0.35s ease',
zIndex: hoveredCardId === 'add' ? 10 : 1, willChange: 'transform, box-shadow',
'&:hover': { '&:hover': {
boxShadow: '0 0 40px rgba(242,113,33,0.7)', boxShadow: '0 0 40px rgba(242,113,33,0.7)',
borderStyle: 'solid', transform: 'scale(1.02)',
zIndex: 10,
}, },
}} }}
onClick={handleAddVersion} onClick={handleAddVersion}
onMouseEnter={() => setHoveredCardId('add')}
onMouseLeave={() => setHoveredCardId(null)}
> >
<AddIcon sx={{ fontSize: '4vw', color: '#fff' }} /> <AddIcon sx={{ fontSize: '4vw', color: '#fff' }} />
<Typography <Typography
@ -285,7 +283,7 @@ export const VersionsExplorer = () => {
return ( return (
<Box <Box
sx={{ sx={{
px: '7vw', px: '5vw',
overflowY: 'auto', overflowY: 'auto',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@ -391,7 +389,6 @@ export const VersionsExplorer = () => {
maxHeight: '80vh', maxHeight: '80vh',
overflowY: 'auto', overflowY: 'auto',
background: 'linear-gradient(145deg, #000000 10%, #8A2387 100%)', background: 'linear-gradient(145deg, #000000 10%, #8A2387 100%)',
border: '1px solid rgba(255,255,255,0.16)',
boxShadow: '0 20px 60px rgba(0,0,0,0.85)', boxShadow: '0 20px 60px rgba(0,0,0,0.85)',
p: 4, p: 4,
borderRadius: '2.5vw', borderRadius: '2.5vw',
@ -426,7 +423,7 @@ export const VersionsExplorer = () => {
borderRadius: '1vw', borderRadius: '1vw',
mb: 1, mb: 1,
backgroundColor: 'rgba(0, 0, 0, 0.35)', backgroundColor: 'rgba(0, 0, 0, 0.35)',
border: '1px solid rgba(255,255,255,0.08)', border: '1px solid rgba(20,20,20,0.2)',
cursor: 'pointer', cursor: 'pointer',
transition: transition:
'background-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease', 'background-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease',