redesign login/registration. add autologin from registration. add 'continue' register from accept acc

This commit is contained in:
aurinex
2025-12-21 01:15:53 +05:00
parent 779f8f779d
commit 4b8e535c58
7 changed files with 1522 additions and 566 deletions

View File

@ -289,6 +289,7 @@ export default function TopUpPage() {
navigate('/');
}}
sx={{
fontFamily: 'Benzin-Bold',
borderRadius: 999,
px: 3,
borderColor: 'rgba(255,255,255,0.18)',
@ -440,11 +441,11 @@ export default function TopUpPage() {
>
<StyledTextField
label="Сколько монет нужно?"
type="number"
value={coins}
onChange={(e) => setCoins(Number(e.target.value))}
inputProps={{ min: 0, step: 1 }}
fullWidth
sx={{'& .MuiFormLabel-root': {fontFamily: 'Benzin-Bold'}}}
/>
<Box

View File

@ -1,10 +1,27 @@
import { Box, Button, Typography } from '@mui/material';
import {
Box,
Button,
Typography,
Paper,
Stack,
Divider,
ToggleButton,
ToggleButtonGroup,
IconButton,
} from '@mui/material';
import useAuth from '../hooks/useAuth';
import { useNavigate } from 'react-router-dom';
import PopaPopa from '../components/popa-popa';
import useConfig from '../hooks/useConfig';
import { useEffect, useMemo, useRef, useState } from 'react';
import { FullScreenLoader } from '../components/FullScreenLoader';
import { styled, alpha, keyframes } from '@mui/material/styles';
import TelegramIcon from '@mui/icons-material/Telegram';
import KeyIcon from '@mui/icons-material/Key';
import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
import PersonAddAlt1RoundedIcon from '@mui/icons-material/PersonAddAlt1Rounded';
import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded';
import React from 'react';
import CustomNotification from '../components/Notifications/CustomNotification';
@ -22,10 +39,106 @@ import GradientTextField from '../components/GradientTextField';
// твои API методы
import { qrInit, qrStatus } from '../api';
import { loadPending } from '../utils/pendingVerification';
interface LoginProps {
onLoginSuccess?: (username: string) => void;
}
const glowPulse = keyframes`
0% { transform: translate3d(0,0,0) scale(1); opacity: .85; filter: saturate(1.0); }
50% { transform: translate3d(0,-6px,0) scale(1.02); opacity: 1; filter: saturate(1.15); }
100% { transform: translate3d(0,0,0) scale(1); opacity: .85; filter: saturate(1.0); }
`;
const borderShimmer = keyframes`
0% { background-position: 0% 50%; opacity: .35; }
50% { background-position: 100% 50%; opacity: .55; }
100% { background-position: 0% 50%; opacity: .35; }
`;
const GRADIENT =
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
const GlassPaper = styled(Paper)(() => ({
position: 'relative',
overflow: 'hidden',
borderRadius: 28,
background: 'rgba(0,0,0,0.35)',
border: '1px solid rgba(255,255,255,0.10)',
backdropFilter: 'blur(14px)',
boxShadow: '0 20px 60px rgba(0,0,0,0.45)',
}));
const Glow = styled('div')(() => ({
position: 'absolute',
inset: -2,
background:
'radial-gradient(800px 300px at 20% 10%, rgba(242,113,33,0.22), transparent 60%),' +
'radial-gradient(800px 300px at 80% 0%, rgba(233,64,205,0.18), transparent 55%),' +
'radial-gradient(900px 420px at 50% 110%, rgba(138,35,135,0.20), transparent 60%)',
pointerEvents: 'none',
animation: `${glowPulse} 6s ease-in-out infinite`,
}));
const GradientTitle = styled(Typography)(() => ({
fontWeight: 900,
backgroundImage:
'linear-gradient(136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
fontFamily: 'Benzin-Bold, sans-serif',
}));
const GradientButton = styled(Button)(() => ({
background: GRADIENT,
fontFamily: 'Benzin-Bold, sans-serif',
borderRadius: 999,
textTransform: 'none',
transition: 'transform 0.25s ease, filter 0.25s ease, box-shadow 0.25s ease',
boxShadow: '0 12px 30px rgba(0,0,0,0.35)',
'&:hover': {
transform: 'scale(1.04)',
filter: 'brightness(1.06)',
boxShadow: '0 16px 42px rgba(0,0,0,0.48)',
background: GRADIENT,
},
'&:disabled': {
background: 'rgba(255,255,255,0.08)',
color: 'rgba(255,255,255,0.35)',
boxShadow: 'none',
},
}));
const Segmented = styled(ToggleButtonGroup)(() => ({
borderRadius: 999,
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.10)',
background: 'rgba(255,255,255,0.06)',
'& .MuiToggleButton-root': {
border: 'none',
color: 'rgba(255,255,255,0.75)',
fontFamily: 'Benzin-Bold, sans-serif',
letterSpacing: '0.02em',
paddingTop: 10,
paddingBottom: 10,
paddingLeft: 18,
paddingRight: 18,
transition: 'transform 0.2s ease, background 0.2s ease, color 0.2s ease',
},
'& .MuiToggleButton-root:hover': {
background: 'rgba(255,255,255,0.08)',
transform: 'scale(1.02)',
},
'& .MuiToggleButton-root.Mui-selected': {
color: '#fff',
background: GRADIENT,
},
'& .MuiToggleButton-root.Mui-selected:hover': {
background: GRADIENT,
},
}));
const Login = ({ onLoginSuccess }: LoginProps) => {
const navigate = useNavigate();
const { config, saveConfig, handleInputChange } = useConfig();
@ -40,6 +153,9 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
const [qrUrl, setQrUrl] = useState<string>('');
const qrRef = useRef<HTMLDivElement | null>(null);
const pollTimerRef = useRef<number | null>(null);
const [qrState, setQrState] = useState<'idle' | 'ready' | 'polling' | 'expired'>('idle');
const [pendingCount, setPendingCount] = useState(0);
// хранит один инстанс QRCodeStyling
const qrInstanceRef = useRef<QRCodeStyling | null>(null);
@ -67,6 +183,24 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
horizontal: 'center',
});
useEffect(() => {
const list = loadPending();
setPendingCount(list.length);
}, []);
const handleContinuePending = () => {
const list = loadPending();
if (!list.length) return;
const last = list[0];
// чтобы Registration подхватил и сразу открыл verification
// можно также записать в launcher_config — удобно
saveConfig({ username: last.username, password: last.password ?? '' });
navigate('/registration', { replace: true });
};
const showNotification = (
message: React.ReactNode,
severity: 'success' | 'info' | 'warning' | 'error' = 'info',
@ -145,13 +279,16 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
const startQrLogin = async () => {
setQrLoading(true);
setQrState('idle');
setQrUrl('');
stopQrPolling();
try {
const init = await qrInit(deviceId);
setQrUrl(init.qr_url);
setQrState('ready');
setQrState('polling');
pollTimerRef.current = window.setInterval(async () => {
try {
const res = await qrStatus(init.token, deviceId);
@ -178,6 +315,7 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
if (res.status === 'expired') {
stopQrPolling();
setQrState('expired');
showNotification('QR-код истёк. Нажми “Обновить QR”.', 'warning');
}
} catch {
@ -320,147 +458,421 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '1vh',
width: '50vw',
mx: 'auto',
width: '100%',
minHeight: 'calc(100vh - 8vh)',
display: 'grid',
placeItems: 'center',
px: '2vw',
}}
>
{loading ? (
<FullScreenLoader message="Входим..." />
) : (
<>
<GlassPaper
sx={{
// width: 'min(64vw, 980px)',
borderRadius: '2.2vw',
}}
>
<Glow />
{/* ===== QR экран по умолчанию ===== */}
{!showPasswordLogin ? (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
alignItems: 'center',
}}
>
<PopaPopa/>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '2vw',
backgroundImage:
'linear-gradient( 136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
Вход через Telegram
</Typography>
<Box sx={{ position: 'relative', p: '2.2vw' }}>
{/* header */}
<Stack alignItems="center">
<PopaPopa />
<Button
variant="contained"
sx={primaryButtonSx}
onClick={() => qrUrl && window.open(qrUrl, '_blank')}
disabled={!qrUrl}
>
Открыть бота
</Button>
{!showPasswordLogin ? (
<Stack direction="row" spacing={1} alignItems="center" sx={{ my: '1vw' }}>
<GradientTitle sx={{ fontSize: 'clamp(16px, 1.2vw, 18px)' }}>
Вход через Telegram
</GradientTitle>
{/* QR контейнер */}
<div ref={qrRef} style={{ minHeight: 300 }} />
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
gap: '2vh',
}}
>
{qrLoading && <FullScreenLoader fullScreen={true} /> }
<Typography onClick={startQrLogin} sx={gradientTextSx}>
Обновить QR
</Typography>
<Typography onClick={goToPasswordLogin} sx={gradientTextSx}>
Войти по логину и паролю
</Typography>
<Typography
onClick={() => navigate('/registration')}
sx={gradientTextSx}
<Box
sx={{
px: 1.2,
py: 0.45,
borderRadius: 999,
fontSize: 'clamp(10px, 0.75vw, 12px)',
fontWeight: 900,
letterSpacing: '0.03em',
color: 'rgba(255,255,255,0.9)',
border: '1px solid rgba(255,255,255,0.10)',
background:
qrState === 'polling'
? 'rgba(255,255,255,0.06)'
: qrState === 'expired'
? 'rgba(255,60,60,0.12)'
: 'rgba(255,255,255,0.06)',
}}
>
Зарегистрироваться
{qrState === 'polling' ? 'ожидание' : qrState === 'expired' ? 'истёк' : 'готов'}
</Box>
</Stack>
) : (
<Stack direction="row" spacing={1} alignItems="center" sx={{ my: '1vw' }}>
<GradientTitle sx={{ fontSize: 'clamp(16px, 1.2vw, 18px)' }}>
Вход по логину и паролю
</GradientTitle>
</Stack>
)}
{/* segmented */}
{pendingCount > 0 ? (
<Box sx={{ mb: '1vw' }}>
{/* <Button
fullWidth
onClick={handleContinuePending}
sx={{
borderRadius: 999,
py: 1.1,
textTransform: 'none',
fontFamily: 'Benzin-Bold, sans-serif',
color: '#fff',
border: '1px solid rgba(255,255,255,0.14)',
background: 'rgba(255,255,255,0.06)',
'&:hover': { background: 'rgba(255,255,255,0.09)' },
}}
>
У вас {pendingCount} неподтвержденный аккаунт. Подтвердить сейчас
</Button> */}
<Typography>
У вас {pendingCount} неподтвержденный аккаунт. <span onClick={handleContinuePending} style={{ cursor: 'pointer', borderBottom: '1px solid #fff'}}>Подтвердить сейчас</span>
</Typography>
</Box>
</Box>
) : (
/* ===== экран логин/пароль (в стиле Registration.tsx) ===== */
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1,
}}
>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '2vw',
backgroundImage:
'linear-gradient( 136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
) : (
<Segmented
exclusive
value={showPasswordLogin ? 'password' : 'qr'}
onChange={(_, v) => {
if (!v) return;
if (v === 'password') goToPasswordLogin();
else backToQr();
}}
sx={{ mb: '1vw' }}
>
Вход по логину и паролю
</Typography>
<ToggleButton value="qr">
<Stack direction="row" spacing={1} alignItems="center">
<TelegramIcon sx={{ fontSize: 18 }} />
<span style={{textTransform: 'none'}}>Telegram QR</span>
</Stack>
</ToggleButton>
<GradientTextField
label="Никнейм"
required
name="username"
value={config.username}
onChange={handleInputChange}
/>
<ToggleButton value="password">
<Stack direction="row" spacing={1} alignItems="center">
<KeyIcon sx={{ fontSize: 18 }} />
<span style={{textTransform: 'none'}}>Логин + пароль</span>
</Stack>
</ToggleButton>
</Segmented>
)}
</Stack>
<GradientTextField
label="Пароль"
required
name="password"
type="password"
value={config.password}
onChange={handleInputChange}
/>
<Button
variant="contained"
sx={primaryButtonSx}
onClick={handleLogin}
>
Войти
</Button>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)', mb: '1.6vw' }} />
{/* content */}
{!showPasswordLogin ? (
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
gap: '2vh',
mt: 2,
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '1.05fr 0.95fr' },
gap: '1.6vw',
alignItems: 'start',
}}
>
<Typography
onClick={() => navigate('/registration')}
sx={gradientTextSx}
{/* QR card */}
<Box
sx={{
position: 'relative',
borderRadius: '1.6vw',
border: '1px solid rgba(255,255,255,0.10)',
background:
'linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.03))',
p: '1.2vw',
overflow: 'hidden',
}}
>
Зарегистрироваться
</Typography>
<Typography onClick={backToQr} sx={gradientTextSx}>
Назад к QR
</Typography>
{/* subtle top glow like marketplace cards */}
<Box
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
background:
'radial-gradient(circle at top, rgba(242,113,33,0.18), transparent 60%)',
opacity: 0.9,
}}
/>
<Box sx={{ position: 'relative' }}>
{/* IMPORTANT: relative wrapper so expired overlay is positioned correctly */}
<Box
sx={{
position: 'relative',
display: 'grid',
placeItems: 'center',
borderRadius: '1.2vw',
border: '1px solid rgba(255,255,255,0.12)',
background:
'linear-gradient(135deg, rgba(40,40,40,0.55), rgba(15,15,15,0.55))',
// minHeight: 340,
// py: 2,
boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.04)',
overflow: 'hidden',
}}
>
<Box
sx={{
position: 'absolute',
inset: -2,
borderRadius: '1.3vw',
padding: '2px',
background:
'linear-gradient(90deg, rgba(242,113,33,0.0), rgba(242,113,33,0.35), rgba(233,64,205,0.35), rgba(138,35,135,0.35), rgba(242,113,33,0.0))',
backgroundSize: '240% 240%',
animation: `${borderShimmer} 7s ease-in-out infinite`,
pointerEvents: 'none',
mask:
'linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)',
WebkitMask:
'linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)',
maskComposite: 'exclude',
WebkitMaskComposite: 'xor',
opacity: qrState === 'expired' ? 0.18 : 0.45,
}}
/>
<div ref={qrRef} style={{ minHeight: 300 }} />
{qrState === 'expired' && (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'grid',
placeItems: 'center',
background:
'linear-gradient(180deg, rgba(0,0,0,0.60), rgba(0,0,0,0.35))',
backdropFilter: 'blur(10px)',
textAlign: 'center',
borderRadius: '1.2vw',
px: 2,
}}
>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: 'clamp(14px, 1.05vw, 18px)',
color: alpha('#fff', 0.92),
}}
>
QR-код истёк
</Typography>
<Typography
sx={{
mt: 0.6,
fontSize: 'clamp(12px, 0.9vw, 14px)',
color: alpha('#fff', 0.75),
}}
>
Нажми Обновить QR, чтобы получить новый
</Typography>
</Box>
)}
</Box>
<Typography
sx={{
mt: 1,
textAlign: 'center',
fontSize: 'clamp(12px, 0.9vw, 14px)',
color: alpha('#fff', 0.75),
fontWeight: 700,
}}
>
{qrState === 'polling' && 'Ожидаем подтверждение…'}
{qrState === 'ready' && 'Сканируй QR в Telegram'}
{qrState === 'expired' && 'Нужно обновить QR'}
{qrState === 'idle' && 'Подготавливаем вход…'}
</Typography>
</Box>
</Box>
{/* actions */}
<Stack spacing={1.2} sx={{ pt: { xs: 0, md: '0.4vw' } }}>
<GradientTitle sx={{ fontSize: 'clamp(16px, 1.2vw, 18px)' }}>
Вход через Telegram
</GradientTitle>
<Typography
sx={{
color: 'rgba(255,255,255,0.70)',
fontWeight: 700,
fontSize: 'clamp(12px, 0.9vw, 14px)',
lineHeight: 1.35,
}}
>
1) Открой бота <br />
2) Сканируй QR <br />
3) Подтверди вход
</Typography>
<GradientButton
variant="contained"
onClick={() => qrUrl && window.open(qrUrl, '_blank')}
disabled={!qrUrl}
startIcon={<OpenInNewRoundedIcon />}
sx={{ py: 1.2, fontSize: 'clamp(12px, 0.95vw, 14px)' }}
>
Открыть бота
</GradientButton>
<Button
disableRipple
disableFocusRipple
onClick={startQrLogin}
startIcon={<RefreshRoundedIcon />}
sx={{
borderRadius: 999,
textTransform: 'none',
py: 1.0,
fontFamily: 'Benzin-Bold',
color: '#fff',
border: '1px solid rgba(255,255,255,0.12)',
background: 'rgba(255,255,255,0.06)',
'&:hover': { background: 'rgba(255,255,255,0.08)' },
}}
>
Обновить QR
</Button>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)', my: 0.6 }} />
<Button
disableRipple
disableFocusRipple
onClick={() => navigate('/registration')}
startIcon={<PersonAddAlt1RoundedIcon />}
sx={{
textTransform: 'none',
borderRadius: 999,
py: 1.0,
fontFamily: 'Benzin-Bold',
color: '#fff',
background: 'rgba(255,255,255,0.06)',
'&:hover': { background: 'rgba(255,255,255,0.08)' },
}}
>
Зарегистрироваться
</Button>
{qrLoading && <FullScreenLoader fullScreen={false} message="Генерируем QR..." />}
</Stack>
</Box>
</Box>
)}
</>
) : (
/* password */
<Box sx={{ display: 'grid', placeItems: 'center' }}>
<Box
sx={{
width: 'min(520px, 100%)',
borderRadius: '1.6vw',
border: '1px solid rgba(255,255,255,0.10)',
background:
'linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.03))',
p: '1.6vw',
}}
>
<GradientTitle sx={{ fontSize: 'clamp(16px, 1.2vw, 18px)', mb: 1 }}>
Вход по логину и паролю
</GradientTitle>
<Stack spacing={1.2}>
<GradientTextField
label="Никнейм"
required
name="username"
value={config.username}
onChange={handleInputChange}
sx={{
'& .MuiInputLabel-root.Mui-focused': {
display: 'none',
},
'& .MuiInputLabel-root.MuiInputLabel-shrink': {
display: 'none',
},
}}
/>
<GradientTextField
label="Пароль"
required
name="password"
type="password"
value={config.password}
onChange={handleInputChange}
sx={{
'& .MuiInputLabel-root.Mui-focused': {
display: 'none',
},
'& .MuiInputLabel-root.MuiInputLabel-shrink': {
display: 'none',
},
}}
/>
<GradientButton
variant="contained"
onClick={handleLogin}
sx={{
py: 1.2,
fontSize: 'clamp(12px, 0.95vw, 14px)',
mt: 0.6,
}}
>
Войти
</GradientButton>
<Stack direction="row" spacing={1}>
<Button
fullWidth
disableRipple
disableFocusRipple
onClick={() => navigate('/registration')}
sx={{
textTransform: 'none',
borderRadius: 999,
py: 1.0,
fontFamily: 'Benzin-Bold',
color: '#fff',
background: 'rgba(255,255,255,0.06)',
'&:hover': { background: 'rgba(255,255,255,0.08)' },
}}
>
Регистрация
</Button>
<Button
fullWidth
disableRipple
disableFocusRipple
onClick={backToQr}
sx={{
textTransform: 'none',
borderRadius: 999,
py: 1.0,
fontFamily: 'Benzin-Bold',
color: '#fff',
background: 'rgba(255,255,255,0.06)',
'&:hover': { background: 'rgba(255,255,255,0.08)' },
}}
>
Назад к QR
</Button>
</Stack>
</Stack>
</Box>
</Box>
)}
</Box>
</GlassPaper>
)}
<CustomNotification

View File

@ -223,20 +223,14 @@ export default function Marketplace() {
const parsed = Number(editPriceValue);
if (!Number.isFinite(parsed) || parsed <= 0) {
showNotification('Цена должна быть числом больше 0', 'warning', {
vertical: 'bottom',
horizontal: 'left',
});
showNotification('Цена должна быть числом больше 0', 'warning');
return;
}
try {
await updateMarketplaceItemPrice(username, editItem.id, parsed);
showNotification('Цена обновлена', 'success', {
vertical: 'bottom',
horizontal: 'left',
});
showNotification('Цена обновлена', 'success');
setEditPriceOpen(false);
setEditItem(null);
@ -248,7 +242,6 @@ export default function Marketplace() {
showNotification(
e instanceof Error ? e.message : 'Ошибка при изменении цены',
'error',
{ vertical: 'bottom', horizontal: 'left' },
);
}
};
@ -256,13 +249,18 @@ export default function Marketplace() {
const handleRemoveItem = async () => {
if (!username || !selectedServer || !removeItem) return;
if (!canBuyOnSelectedServer) {
showNotification(
'Для снятия товара с продажи нужно быть на сервере.',
'error',
);
return;
}
try {
await cancelMarketplaceItemSale(username, removeItem.id);
showNotification('Товар снят с продажи', 'success', {
vertical: 'bottom',
horizontal: 'left',
});
showNotification('Товар снят с продажи', 'success');
setRemoveOpen(false);
setRemoveItem(null);
@ -274,7 +272,6 @@ export default function Marketplace() {
showNotification(
e instanceof Error ? e.message : 'Ошибка при снятии товара',
'error',
{ vertical: 'bottom', horizontal: 'left' },
);
}
};
@ -332,7 +329,6 @@ export default function Marketplace() {
showNotification(
'Чтобы покупать предметы, нужно находиться на сервере игры.',
'warning',
{ vertical: 'bottom', horizontal: 'left' },
);
return;
}
@ -346,7 +342,6 @@ export default function Marketplace() {
<b>{translateServer(selectedServer?.name ?? '')}</b>.
</>,
'info',
{ vertical: 'bottom', horizontal: 'left' },
);
return;
}
@ -359,7 +354,6 @@ export default function Marketplace() {
showNotification(
result.message || 'Предмет успешно куплен! Он будет добавлен в ваш инвентарь.',
'success',
{ vertical: 'bottom', horizontal: 'left' },
);
// обновляем список предметов
@ -369,7 +363,6 @@ export default function Marketplace() {
showNotification(
error instanceof Error ? error.message : 'Ошибка при покупке предмета',
'error',
{ vertical: 'bottom', horizontal: 'left' },
);
}
};
@ -1167,10 +1160,7 @@ export default function Marketplace() {
serverIp={playerServer.ip}
onSellSuccess={() => {
if (selectedServer) loadMarketItems(selectedServer.ip, 1);
showNotification('Предмет успешно выставлен на продажу!', 'success', {
vertical: 'bottom',
horizontal: 'left',
});
showNotification('Предмет успешно выставлен на продажу!', 'success');
}}
/>
) : (

File diff suppressed because it is too large Load Diff

View File

@ -61,6 +61,9 @@ function getRarityColor(weight?: number): string {
}
}
const GRADIENT =
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
export default function Shop() {
const [storeCapes, setStoreCapes] = useState<StoreCape[]>([]);
const [userCapes, setUserCapes] = useState<Cape[]>([]);
@ -120,6 +123,18 @@ export default function Shop() {
type: 'success',
});
const showNotification = (
message: React.ReactNode,
severity: 'success' | 'info' | 'warning' | 'error' = 'info',
position: NotificationPosition = getNotifPositionFromSettings(),
) => {
if (!isNotificationsEnabled()) return;
setNotifMsg(message);
setNotifSeverity(severity);
setNotifPos(position);
setNotifOpen(true);
};
const loadBonuses = async (username: string) => {
try {
setBonusesLoading(true);
@ -133,10 +148,7 @@ export default function Shop() {
console.error('Ошибка при получении прокачек:', error);
if (!isNotificationsEnabled()) return;
setNotifMsg('Ошибка при загрузке прокачки!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
showNotification('Ошибка при загрузке прокачки!', 'error')
} finally {
setBonusesLoading(false);
}
@ -182,18 +194,12 @@ export default function Shop() {
playBuySound();
if (!isNotificationsEnabled()) return;
setNotifMsg('Плащ успешно куплен!');
setNotifSeverity('success');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
showNotification('Плащ успешно куплен!', 'success')
} catch (error) {
console.error('Ошибка при покупке плаща:', error);
if (!isNotificationsEnabled()) return;
setNotifMsg('Ошибка при покупке плаща!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
showNotification('Ошибка при покупке плаща!', 'error')
}
};
@ -275,10 +281,7 @@ export default function Shop() {
if (!username) {
if (!isNotificationsEnabled()) return;
setNotifMsg('Не найдено имя игрока. Авторизируйтесь в лаунчере!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
showNotification('Не найдено имя игрока. Авторизируйтесь в лаунчере!', 'error')
return;
}
@ -291,18 +294,12 @@ export default function Shop() {
await loadBonuses(username);
if (!isNotificationsEnabled()) return;
setNotifMsg('Прокачка успешно куплена!');
setNotifSeverity('success');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
showNotification('Прокачка успешно куплена!', 'success')
} catch (error) {
console.error('Ошибка при покупке прокачки:', error);
if (!isNotificationsEnabled()) return;
setNotifMsg('Ошибка при прокачке!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
showNotification('Ошибка при прокачке!', 'error')
}
});
};
@ -317,18 +314,12 @@ export default function Shop() {
await loadBonuses(username);
if (!isNotificationsEnabled()) return;
setNotifMsg('Бонус улучшен!');
setNotifSeverity('success');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
showNotification('Бонус улучшен!', 'success')
} catch (error) {
console.error('Ошибка при улучшении бонуса:', error);
if (!isNotificationsEnabled()) return;
setNotifMsg('Ошибка при улучшении бонуса!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
showNotification('Ошибка при улучшении бонуса!', 'error')
}
});
};
@ -343,10 +334,7 @@ export default function Shop() {
} catch (error) {
console.error('Ошибка при переключении бонуса:', error);
if (!isNotificationsEnabled()) return;
setNotifMsg('Ошибка при переключении бонуса!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
showNotification('Ошибка при переключении бонуса!', 'error')
}
});
};
@ -389,33 +377,22 @@ const filteredCases = (cases || []).filter((c) => {
const handleOpenCase = async (caseData: Case) => {
if (!username) {
if (!isNotificationsEnabled()) return;
setNotifMsg('Не найдено имя игрока. Авторизуйтесь в лаунчере!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
showNotification('Не найдено имя игрока. Авторизуйтесь в лаунчере!', 'error')
return;
}
if (!selectedCaseServerIp) {
if (!isNotificationsEnabled()) return;
setNotifMsg('Выберите сервер для открытия кейса!');
setNotifSeverity('warning');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
showNotification('Выберите сервер для открытия кейса!', 'warning')
return;
}
const allowedIps = caseData.server_ips || [];
if (allowedIps.length > 0 && !allowedIps.includes(selectedCaseServerIp)) {
if (!isNotificationsEnabled()) return;
setNotifMsg(
`Этот кейс доступен на: ${allowedIps
showNotification(`Этот кейс доступен на: ${allowedIps
.map((ip) => translateServer(`Server ${ip}`))
.join(', ')}`,
);
setNotifSeverity('warning');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
.join(', ')}`, 'warning')
return;
}
@ -437,18 +414,12 @@ const filteredCases = (cases || []).filter((c) => {
playBuySound();
if (!isNotificationsEnabled()) return;
setNotifMsg('Кейс открыт!');
setNotifSeverity('success');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
showNotification('Кейс открыт!', 'success')
} catch (error) {
console.error('Ошибка при открытии кейса:', error);
if (!isNotificationsEnabled()) return;
setNotifMsg(String(error instanceof Error ? error.message : 'Ошибка при открытии кейса!'));
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
showNotification((String(error instanceof Error ? error.message : 'Ошибка при открытии кейса!')), 'error')
} finally {
setIsOpening(false);
}
@ -617,28 +588,99 @@ const filteredCases = (cases || []).filter((c) => {
</Typography>
{caseServers.length > 0 && (
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel id="cases-server-label" sx={{ fontFamily: 'Benzin-Bold', color: 'rgba(255,255,255,0.75)' }}>
Сервер
</InputLabel>
<Select
labelId="cases-server-label"
label="Сервер"
value={selectedCaseServerIp}
onChange={(e) => setSelectedCaseServerIp(String(e.target.value))}
MenuProps={{
<FormControl
size="small"
sx={{
// textTransform: 'uppercase',
// чтобы по ширине вел себя как бейдж
// minWidth: '18vw',
// maxWidth: '28vw',
'& .MuiInputLabel-root': { display: 'none' },
'& .MuiOutlinedInput-root': {
borderRadius: '3vw',
position: 'relative',
px: '1.2vw',
py: '0.5vw',
color: 'rgba(255,255,255,0.95)',
background:
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.20), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.16), transparent 55%), rgba(10,10,20,0.92)',
border: '1px solid rgba(255,255,255,0.10)',
boxShadow: '0 1.4vw 3.8vw rgba(0,0,0,0.55)',
backdropFilter: 'blur(14px)',
overflow: 'hidden',
transition: 'transform 0.18s ease, filter 0.18s ease',
'&:hover': {
transform: 'scale(1.02)',
// filter: 'brightness(1.03)',
},
'& .MuiOutlinedInput-notchedOutline': {
border: 'none',
},
// нижняя градиентная полоска как у username
'&:after': {
content: '""',
position: 'absolute',
left: '0%',
right: '0%',
bottom: 0,
height: '0.15vw',
borderRadius: '999px',
background: GRADIENT,
opacity: 0.9,
pointerEvents: 'none',
width: '100%',
},
},
'& .MuiSelect-select': {
// стиль текста как у плашки username
fontFamily: 'Benzin-Bold',
fontSize: '1.9vw',
lineHeight: 1.1,
textAlign: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
// убираем “инпутный” паддинг
padding: 0,
minHeight: 'unset',
// чтобы длинные названия не ломали
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
}}
>
<Select
labelId="cases-server-label"
label="Сервер"
value={selectedCaseServerIp}
onChange={(e) => setSelectedCaseServerIp(String(e.target.value))}
MenuProps={{
PaperProps: {
sx: {
mt: 1,
bgcolor: 'rgba(10,10,20,0.96)',
border: '1px solid rgba(255,255,255,0.10)',
borderRadius: '1vw',
backdropFilter: 'blur(14px)',
'& .MuiMenuItem-root': {
color: 'rgba(255,255,255,0.9)',
fontFamily: 'Benzin-Bold',
fontSize: '1.1vw',
},
'& .MuiMenuItem-root.Mui-selected': {
backgroundColor: 'rgba(242,113,33,0.16)',
backgroundColor: 'rgba(242,113,33,0.18)',
},
'& .MuiMenuItem-root:hover': {
backgroundColor: 'rgba(233,64,205,0.14)',
@ -646,37 +688,75 @@ const filteredCases = (cases || []).filter((c) => {
},
},
}}
sx={{
borderRadius: '999px',
bgcolor: 'rgba(255,255,255,0.04)',
color: 'rgba(255,255,255,0.92)',
fontFamily: 'Benzin-Bold',
'& .MuiSelect-select': {
py: '0.9vw',
px: '1.2vw',
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(255,255,255,0.14)',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(242,113,33,0.55)',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(233,64,205,0.65)',
borderWidth: '2px',
},
'& .MuiSelect-icon': {
color: 'rgba(255,255,255,0.75)',
},
}}
>
{caseServers.map((ip) => (
<MenuItem key={ip} value={ip}>
{translateServer(`Server ${ip}`)}
</MenuItem>
))}
</Select>
</FormControl>
>
{caseServers.map((ip) => (
<MenuItem key={ip} value={ip}>
{translateServer(`Server ${ip}`)}
</MenuItem>
))}
</Select>
</FormControl>
// <FormControl size="small" sx={{ minWidth: 220 }}>
// <InputLabel id="cases-server-label" sx={{ fontFamily: 'Benzin-Bold', color: 'rgba(255,255,255,0.75)' }}>
// Сервер
// </InputLabel>
// <Select
// labelId="cases-server-label"
// label="Сервер"
// value={selectedCaseServerIp}
// onChange={(e) => setSelectedCaseServerIp(String(e.target.value))}
// MenuProps={{
// PaperProps: {
// sx: {
// bgcolor: 'rgba(10,10,20,0.96)',
// border: '1px solid rgba(255,255,255,0.10)',
// borderRadius: '1vw',
// backdropFilter: 'blur(14px)',
// '& .MuiMenuItem-root': {
// color: 'rgba(255,255,255,0.9)',
// fontFamily: 'Benzin-Bold',
// },
// '& .MuiMenuItem-root.Mui-selected': {
// backgroundColor: 'rgba(242,113,33,0.16)',
// },
// '& .MuiMenuItem-root:hover': {
// backgroundColor: 'rgba(233,64,205,0.14)',
// },
// },
// },
// }}
// sx={{
// borderRadius: '999px',
// bgcolor: 'rgba(255,255,255,0.04)',
// color: 'rgba(255,255,255,0.92)',
// fontFamily: 'Benzin-Bold',
// '& .MuiSelect-select': {
// py: '0.9vw',
// px: '1.2vw',
// },
// '& .MuiOutlinedInput-notchedOutline': {
// borderColor: 'rgba(255,255,255,0.14)',
// },
// '&:hover .MuiOutlinedInput-notchedOutline': {
// borderColor: 'rgba(242,113,33,0.55)',
// },
// '&.Mui-focused .MuiOutlinedInput-notchedOutline': {
// borderColor: 'rgba(233,64,205,0.65)',
// borderWidth: '2px',
// },
// '& .MuiSelect-icon': {
// color: 'rgba(255,255,255,0.75)',
// },
// }}
// >
// {caseServers.map((ip) => (
// <MenuItem key={ip} value={ip}>
// {translateServer(`Server ${ip}`)}
// </MenuItem>
// ))}
// </Select>
// </FormControl>
)}
</Box>

View File

@ -0,0 +1,43 @@
export type PendingVerification = {
username: string;
password?: string;
createdAt: number;
};
const PENDING_KEY = 'pending_verifications_v1';
export const loadPending = (): PendingVerification[] => {
try {
const raw = localStorage.getItem(PENDING_KEY);
return raw ? (JSON.parse(raw) as PendingVerification[]) : [];
} catch {
return [];
}
};
const savePending = (items: PendingVerification[]) => {
localStorage.setItem(PENDING_KEY, JSON.stringify(items));
};
export const upsertPending = (item: PendingVerification) => {
const list = loadPending();
const next = [
item,
...list.filter(
(x) => x.username.toLowerCase() !== item.username.toLowerCase(),
),
].slice(0, 5);
savePending(next);
};
export const removePending = (username: string) => {
const list = loadPending();
savePending(
list.filter((x) => x.username.toLowerCase() !== username.toLowerCase()),
);
};
export const clearPending = () => {
localStorage.removeItem(PENDING_KEY);
};