add new page promocode

This commit is contained in:
2025-12-20 20:49:49 +05:00
parent 5f23adc9ae
commit f8b358d9bd
7 changed files with 260 additions and 21 deletions

10
package-lock.json generated
View File

@ -28,6 +28,7 @@
"qr-code-styling": "^1.9.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.3.0",
"remark-gfm": "^4.0.1",
@ -20357,6 +20358,15 @@
"react": "^19.2.1"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.1.tgz",

View File

@ -125,6 +125,7 @@
"qr-code-styling": "^1.9.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.3.0",
"remark-gfm": "^4.0.1",

View File

@ -29,6 +29,7 @@ import Inventory from './pages/Inventory';
import FakePaymentPage from './pages/FakePaymentPage';
import { TrayBridge } from './utils/TrayBridge';
import { API_BASE_URL } from './api';
import { PromoRedeem } from './pages/PromoRedeem';
const AuthCheck = ({ children }: { children: ReactNode }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
@ -407,6 +408,14 @@ const AppLayout = () => {
</AuthCheck>
}
/>
<Route
path="/promocode"
element={
<AuthCheck>
<PromoRedeem />
</AuthCheck>
}
/>
</Routes>
</Box>
</Box>

View File

@ -0,0 +1,32 @@
import { API_BASE_URL } from '../api';
export interface RedeemPromoResponse {
code: string;
reward_coins: number;
new_balance: number;
}
export async function redeemPromoCode(
username: string,
code: string,
): Promise<RedeemPromoResponse> {
const formData = new FormData();
formData.append('username', username);
formData.append('code', code);
const response = await fetch(`${API_BASE_URL}/promo/redeem`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
let msg = 'Не удалось активировать промокод';
try {
const errorData = await response.json();
msg = errorData.message || errorData.detail || msg;
} catch {}
throw new Error(msg);
}
return await response.json();
}

View File

@ -35,6 +35,7 @@ export default function PageHeader() {
path === '/profile' ||
path === '/inventory' ||
path === '/fakepaymentpage' ||
path === '/promocode' ||
path.startsWith('/launch')
) {
return { title: '', subtitle: '', hidden: true };

View File

@ -21,7 +21,8 @@ import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
import PersonIcon from '@mui/icons-material/Person';
import SettingsIcon from '@mui/icons-material/Settings';
import { useTheme } from '@mui/material/styles';
import CategoryIcon from '@mui/icons-material/Category';
import InventoryIcon from '@mui/icons-material/Inventory';
import { RiCoupon3Fill } from 'react-icons/ri';
declare global {
interface Window {
@ -589,7 +590,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
theme.launcher.topbar.menuItem,
]}
>
<CategoryIcon sx={{ fontSize: '2vw' }} /> Инвентарь
<InventoryIcon sx={{ fontSize: '2vw' }} /> Инвентарь
</MenuItem>
{/* ===== 2 строка: ежедневные задания ===== */}
@ -633,27 +634,42 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
<SettingsIcon sx={{ fontSize: '2vw' }} /> Настройки
</MenuItem>
<MenuItem
onClick={() => {
handleAvatarMenuClose();
navigate('/promocode');
}}
sx={[
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
theme.launcher.topbar.menuItem,
]}
>
<RiCoupon3Fill style={{ fontSize: '2vw' }} /> Промокоды
</MenuItem>
<Divider sx={{ my: '0.4vw', ...theme.launcher.topbar.menuDivider }} />
{!isLoginPage && !isRegistrationPage && username && (
<Button
variant="outlined"
color="primary"
onClick={() => {
handleAvatarMenuClose();
logout();
}}
sx={[
{
width: '8vw',
height: '3vw',
fontSize: '1.2vw',
m: '0 0 0 18vw',
},
theme.launcher.topbar.logoutButton,
]}
>
Выйти
</Button>
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Button
variant="outlined"
color="primary"
onClick={() => {
handleAvatarMenuClose();
logout();
}}
sx={[
{
width: '90%',
height: '3vw',
fontSize: '1.2vw',
mx: '1vw',
},
theme.launcher.topbar.logoutButton,
]}
>
Выйти
</Button>
</Box>
)}
{/* ↓↓↓ дальше ты сам добавишь пункты ↓↓↓ */}

View File

@ -0,0 +1,170 @@
import { Box, Button, Typography } from '@mui/material';
import { useEffect, useState } from 'react';
import GradientTextField from '../components/GradientTextField';
import CustomNotification from '../components/Notifications/CustomNotification';
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
import {
isNotificationsEnabled,
getNotifPositionFromSettings,
} from '../utils/notifications';
import { redeemPromoCode } from '../api/promocodes';
import { useNavigate } from 'react-router-dom';
export const PromoRedeem = () => {
const navigate = useNavigate();
const [username, setUsername] = useState<string>(''); // будет автозаполнение
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const [notifOpen, setNotifOpen] = useState(false);
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
const [notifSeverity, setNotifSeverity] = useState<
'success' | 'info' | 'warning' | 'error'
>('info');
const [notifPos, setNotifPos] = useState<NotificationPosition>({
vertical: 'bottom',
horizontal: 'center',
});
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);
};
// как в Profile.tsx: читаем launcher_config
useEffect(() => {
try {
const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) {
const config = JSON.parse(savedConfig);
setUsername(config.username || '');
}
} catch {
setUsername('');
}
}, []);
const handleRedeem = async () => {
if (!username) {
showNotification(
'Не удалось определить никнейм. Войдите в аккаунт заново.',
'warning',
);
// по желанию можно отправить в login/profile:
// navigate('/login', { replace: true });
return;
}
if (!code) {
showNotification('Введите промокод', 'warning');
return;
}
setLoading(true);
try {
const res = await redeemPromoCode(username, code);
showNotification(
<>
Промокод <b>{res.code}</b> успешно активирован!
</>,
'success',
);
setCode('');
} catch (e: any) {
showNotification(e?.message || 'Ошибка активации промокода', 'error');
} finally {
setLoading(false);
}
};
return (
<>
<Box
sx={{
height: 'calc(100vh - 8vh)',
pt: '8vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
px: 2,
}}
>
<Typography
variant="h4"
sx={{
textAlign: 'center',
fontFamily: 'Benzin-Bold',
backgroundImage:
'linear-gradient( 136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
mb: 2,
}}
>
Активация промокода
</Typography>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '50vw',
}}
>
<GradientTextField
label=""
required
name="code"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
/>
<Button
variant="contained"
color="primary"
disabled={loading}
sx={{
transition: 'transform 0.3s ease',
width: '100%',
mt: 2,
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
fontFamily: 'Benzin-Bold',
borderRadius: '2.5vw',
fontSize: '2vw',
'&:hover': {
transform: loading ? 'none' : 'scale(1.1)',
},
}}
onClick={handleRedeem}
>
{loading ? 'Активируем...' : 'Активировать'}
</Button>
</Box>
<CustomNotification
open={notifOpen}
message={notifMsg}
severity={notifSeverity}
position={notifPos}
onClose={() => setNotifOpen(false)}
autoHideDuration={2500}
/>
</Box>
</>
);
};