add new page promocode
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@ -28,6 +28,7 @@
|
|||||||
"qr-code-styling": "^1.9.2",
|
"qr-code-styling": "^1.9.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.3.0",
|
"react-router-dom": "^7.3.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
@ -20357,6 +20358,15 @@
|
|||||||
"react": "^19.2.1"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "19.2.1",
|
"version": "19.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.1.tgz",
|
||||||
|
|||||||
@ -125,6 +125,7 @@
|
|||||||
"qr-code-styling": "^1.9.2",
|
"qr-code-styling": "^1.9.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.3.0",
|
"react-router-dom": "^7.3.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import Inventory from './pages/Inventory';
|
|||||||
import FakePaymentPage from './pages/FakePaymentPage';
|
import FakePaymentPage from './pages/FakePaymentPage';
|
||||||
import { TrayBridge } from './utils/TrayBridge';
|
import { TrayBridge } from './utils/TrayBridge';
|
||||||
import { API_BASE_URL } from './api';
|
import { API_BASE_URL } from './api';
|
||||||
|
import { PromoRedeem } from './pages/PromoRedeem';
|
||||||
|
|
||||||
const AuthCheck = ({ children }: { children: ReactNode }) => {
|
const AuthCheck = ({ children }: { children: ReactNode }) => {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||||
@ -407,6 +408,14 @@ const AppLayout = () => {
|
|||||||
</AuthCheck>
|
</AuthCheck>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/promocode"
|
||||||
|
element={
|
||||||
|
<AuthCheck>
|
||||||
|
<PromoRedeem />
|
||||||
|
</AuthCheck>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
32
src/renderer/api/promocodes.ts
Normal file
32
src/renderer/api/promocodes.ts
Normal 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();
|
||||||
|
}
|
||||||
@ -35,6 +35,7 @@ export default function PageHeader() {
|
|||||||
path === '/profile' ||
|
path === '/profile' ||
|
||||||
path === '/inventory' ||
|
path === '/inventory' ||
|
||||||
path === '/fakepaymentpage' ||
|
path === '/fakepaymentpage' ||
|
||||||
|
path === '/promocode' ||
|
||||||
path.startsWith('/launch')
|
path.startsWith('/launch')
|
||||||
) {
|
) {
|
||||||
return { title: '', subtitle: '', hidden: true };
|
return { title: '', subtitle: '', hidden: true };
|
||||||
|
|||||||
@ -21,7 +21,8 @@ import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
|
|||||||
import PersonIcon from '@mui/icons-material/Person';
|
import PersonIcon from '@mui/icons-material/Person';
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
import { useTheme } from '@mui/material/styles';
|
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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@ -589,7 +590,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
|||||||
theme.launcher.topbar.menuItem,
|
theme.launcher.topbar.menuItem,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<CategoryIcon sx={{ fontSize: '2vw' }} /> Инвентарь
|
<InventoryIcon sx={{ fontSize: '2vw' }} /> Инвентарь
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
||||||
{/* ===== 2 строка: ежедневные задания ===== */}
|
{/* ===== 2 строка: ежедневные задания ===== */}
|
||||||
@ -633,8 +634,22 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
|||||||
<SettingsIcon sx={{ fontSize: '2vw' }} /> Настройки
|
<SettingsIcon sx={{ fontSize: '2vw' }} /> Настройки
|
||||||
</MenuItem>
|
</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 }} />
|
<Divider sx={{ my: '0.4vw', ...theme.launcher.topbar.menuDivider }} />
|
||||||
{!isLoginPage && !isRegistrationPage && username && (
|
{!isLoginPage && !isRegistrationPage && username && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
@ -644,16 +659,17 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
|||||||
}}
|
}}
|
||||||
sx={[
|
sx={[
|
||||||
{
|
{
|
||||||
width: '8vw',
|
width: '90%',
|
||||||
height: '3vw',
|
height: '3vw',
|
||||||
fontSize: '1.2vw',
|
fontSize: '1.2vw',
|
||||||
m: '0 0 0 18vw',
|
mx: '1vw',
|
||||||
},
|
},
|
||||||
theme.launcher.topbar.logoutButton,
|
theme.launcher.topbar.logoutButton,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
Выйти
|
Выйти
|
||||||
</Button>
|
</Button>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ↓↓↓ дальше ты сам добавишь пункты ↓↓↓ */}
|
{/* ↓↓↓ дальше ты сам добавишь пункты ↓↓↓ */}
|
||||||
|
|||||||
170
src/renderer/pages/PromoRedeem.tsx
Normal file
170
src/renderer/pages/PromoRedeem.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user