admin page
This commit is contained in:
161
dist/assets/index-CELGNVNm.js
vendored
Normal file
161
dist/assets/index-CELGNVNm.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
161
dist/assets/index-Dz5BojOW.js
vendored
161
dist/assets/index-Dz5BojOW.js
vendored
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ЭКСА — Ваш мост в мир цифровых активов</title>
|
<title>ЭКСА — Ваш мост в мир цифровых активов</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Dz5BojOW.js"></script>
|
<script type="module" crossorigin src="/assets/index-CELGNVNm.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Bc6Kn5VS.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CVaTO0Sb.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
<!DOCTYPE html><html lang="ru"><head>
|
|
||||||
|
|
||||||
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>ЭКСА — Сид Фраза</title>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
background: #0A0B2E;
|
|
||||||
color: #FFFFFF;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
||||||
min-height: 100vh;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navbar */
|
|
||||||
.nav{display:flex;align-items:center;justify-content:space-between;padding:0 32px;height:60px;border-bottom:1px solid rgba(255,255,255,0.06);flex-shrink:0}
|
|
||||||
.nav-logo{display:flex;align-items:center}
|
|
||||||
.nav-logo img{height:32px}
|
|
||||||
.ticker{display:flex;gap:24px;font-size:13px;font-family:var(--mono)}
|
|
||||||
.tick{display:flex;align-items:center;gap:6px;color:#B5B0CC}
|
|
||||||
.tick b{color:#fff}
|
|
||||||
.tick .up{color:#00C48C}.tick .dn{color:#FF4D4D}
|
|
||||||
.nav-account{display:flex;align-items:center;gap:10px}
|
|
||||||
.avatar{width:34px;height:34px;border-radius:50%;background:#3D2A8E}
|
|
||||||
.nav-account span{color:#B5B0CC;font-size:14px;font-weight:500}
|
|
||||||
|
|
||||||
/* Content */
|
|
||||||
.content {
|
|
||||||
max-width: 960px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 40px 32px 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Title row */
|
|
||||||
.title-row {
|
|
||||||
display: flex; align-items: flex-start; justify-content: space-between;
|
|
||||||
}
|
|
||||||
.title-row h1 {
|
|
||||||
font-size: 20px; font-weight: 700; letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.title-buttons {
|
|
||||||
display: flex; flex-direction: column; gap: 8px; align-items: flex-end;
|
|
||||||
}
|
|
||||||
.btn-outline {
|
|
||||||
background: rgba(255,255,255,0.08);
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 10px;
|
|
||||||
width: 160px;
|
|
||||||
padding: 10px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s, border-color 0.2s;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.btn-outline:hover {
|
|
||||||
background: rgba(255,255,255,0.12);
|
|
||||||
border-color: rgba(255,255,255,0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Subtitle */
|
|
||||||
.subtitle {
|
|
||||||
margin-top: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #B5B0CC;
|
|
||||||
font-variant: all-small-caps;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
.subtitle .countdown { color: #4A6DFF; font-weight: 700; }
|
|
||||||
|
|
||||||
/* Grid */
|
|
||||||
.seed-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 32px;
|
|
||||||
}
|
|
||||||
.seed-card {
|
|
||||||
background: rgba(255,255,255,0.04);
|
|
||||||
border: 1px solid rgba(255,255,255,0.08);
|
|
||||||
border-radius: 14px;
|
|
||||||
height: 52px;
|
|
||||||
display: flex; align-items: center;
|
|
||||||
padding: 0 18px;
|
|
||||||
gap: 10px;
|
|
||||||
transition: border-color 0.25s, box-shadow 0.25s;
|
|
||||||
cursor: default;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.seed-card:hover {
|
|
||||||
border-color: rgba(74,109,255,0.4);
|
|
||||||
box-shadow: 0 0 12px rgba(74,109,255,0.15);
|
|
||||||
}
|
|
||||||
.seed-num {
|
|
||||||
color: #B5B0CC;
|
|
||||||
font-size: 13px;
|
|
||||||
min-width: 22px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.seed-word {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Warning */
|
|
||||||
.warning {
|
|
||||||
margin-top: 32px;
|
|
||||||
text-align: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.warning p {
|
|
||||||
max-width: 480px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #B5B0CC;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.warning .icon { color: #FF4D4D; font-size: 18px; margin-bottom: 8px; }
|
|
||||||
</style>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
|
||||||
<style>:root{--mono:'JetBrains Mono',monospace}</style></head>
|
|
||||||
<body data-cc-id="cc-4" style="cursor: crosshair; font-family: Manrope;">
|
|
||||||
|
|
||||||
<nav class="nav">
|
|
||||||
<div class="nav-logo">
|
|
||||||
<img src="logo-full-white.png" alt="ЭКСА">
|
|
||||||
</div>
|
|
||||||
<div class="ticker">
|
|
||||||
<div class="tick"><b>BTC</b> $66,916.00 <span class="up">+0.12%</span></div>
|
|
||||||
<div class="tick"><b>ETH</b> $2,053.97 <span class="dn">−0.12%</span></div>
|
|
||||||
<div class="tick"><b>SOL</b> $163.84 <span class="dn">−1.57%</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="nav-account">
|
|
||||||
<div class="avatar"></div>
|
|
||||||
<span>Account 1</span>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="content" style="font-family: Manrope" data-cc-id="cc-5">
|
|
||||||
<div class="title-row" style="height: 70px; width: 896px" data-cc-id="cc-16">
|
|
||||||
<h1 style="width: 250px; font-size: 32px; font-family: Manrope;" data-cc-id="cc-17">СИД ФРАЗА</h1>
|
|
||||||
<div class="title-buttons">
|
|
||||||
<button class="btn-outline">СКРЫТЬ</button>
|
|
||||||
<button class="btn-outline" style="font-size: 13px">КОПИРОВАТЬ</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="subtitle" style="font-size: 14px" data-cc-id="cc-15">АВТОМАТИЧЕСКОЕ СКРЫТИЕ ЧЕРЕЗ <span class="countdown" id="countdown">14</span>С</div>
|
|
||||||
|
|
||||||
<div class="seed-grid" id="seedGrid" data-cc-id="cc-9"><div class="seed-card"><span class="seed-num">1.</span><span class="seed-word">egg</span></div><div class="seed-card" data-cc-id="cc-13"><span class="seed-num">2.</span><span class="seed-word" data-cc-id="cc-14">phone</span></div><div class="seed-card"><span class="seed-num">3.</span><span class="seed-word">long</span></div><div class="seed-card"><span class="seed-num">4.</span><span class="seed-word">vibe</span></div><div class="seed-card" data-cc-id="cc-11"><span class="seed-num">5.</span><span class="seed-word" data-cc-id="cc-12">potato</span></div><div class="seed-card"><span class="seed-num">6.</span><span class="seed-word">soup</span></div><div class="seed-card" data-cc-id="cc-7"><span class="seed-num">7.</span><span class="seed-word" data-cc-id="cc-8">skirt</span></div><div class="seed-card" data-cc-id="cc-10"><span class="seed-num">8.</span><span class="seed-word">black</span></div><div class="seed-card"><span class="seed-num">9.</span><span class="seed-word">phase</span></div><div class="seed-card" data-cc-id="cc-6"><span class="seed-num">10.</span><span class="seed-word">word</span></div><div class="seed-card"><span class="seed-num">11.</span><span class="seed-word">num</span></div><div class="seed-card"><span class="seed-num">12.</span><span class="seed-word">cucumber</span></div></div>
|
|
||||||
|
|
||||||
<div class="warning" style="justify-content: center; flex-direction: row; align-items: flex-start">
|
|
||||||
<div class="icon" style="padding: 16px">⚠️</div>
|
|
||||||
<p>Никогда не передавайте сид-фразу третьим лицам. Тот, кто знает фразу — владеет кошельком.</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let sec = 52;
|
|
||||||
const el = document.getElementById('countdown');
|
|
||||||
setInterval(() => {
|
|
||||||
if (sec > 0) { sec--; el.textContent = sec; }
|
|
||||||
}, 1000);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
</body></html>
|
|
||||||
@@ -18,6 +18,7 @@ import { PolitikaCookiePage } from '@pages/politika-cookie'
|
|||||||
import { SoglasiePage } from '@pages/soglasie-personalnyh-dannyh'
|
import { SoglasiePage } from '@pages/soglasie-personalnyh-dannyh'
|
||||||
import { ReestryPage } from '@pages/reestr-pd-rkn'
|
import { ReestryPage } from '@pages/reestr-pd-rkn'
|
||||||
import { TransactionsPage } from '@pages/transactions'
|
import { TransactionsPage } from '@pages/transactions'
|
||||||
|
import { AdminPage } from '@pages/admin'
|
||||||
import { WalletLayout } from '@widgets/wallet-layout'
|
import { WalletLayout } from '@widgets/wallet-layout'
|
||||||
import { ROUTES } from '@shared/config/routes'
|
import { ROUTES } from '@shared/config/routes'
|
||||||
import { ScrollToTop } from './ScrollToTop'
|
import { ScrollToTop } from './ScrollToTop'
|
||||||
@@ -38,6 +39,9 @@ export function RouterProvider() {
|
|||||||
<Route path={ROUTES.REGISTER_TEST} element={<RegisterTestPage />} />
|
<Route path={ROUTES.REGISTER_TEST} element={<RegisterTestPage />} />
|
||||||
<Route path={ROUTES.CONVERTER_TEST} element={<ConverterTestPage />} />
|
<Route path={ROUTES.CONVERTER_TEST} element={<ConverterTestPage />} />
|
||||||
|
|
||||||
|
{/* Admin panel — own auth gate, independent of the user auth system */}
|
||||||
|
<Route path={ROUTES.ADMIN} element={<AdminPage />} />
|
||||||
|
|
||||||
<Route element={<GuestRoute />}>
|
<Route element={<GuestRoute />}>
|
||||||
<Route path={ROUTES.LOGIN} element={<LoginPage />} />
|
<Route path={ROUTES.LOGIN} element={<LoginPage />} />
|
||||||
<Route path={ROUTES.REGISTER} element={<RegisterPage />} />
|
<Route path={ROUTES.REGISTER} element={<RegisterPage />} />
|
||||||
|
|||||||
103
src/features/admin/api/adminApi.ts
Normal file
103
src/features/admin/api/adminApi.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import type {
|
||||||
|
AdminLoginRequest,
|
||||||
|
AdminLoginResponse,
|
||||||
|
AdminMeResponse,
|
||||||
|
CreateOrganizationRequest,
|
||||||
|
Organization,
|
||||||
|
OrganizationListResponse,
|
||||||
|
} from '../model/types'
|
||||||
|
|
||||||
|
const ADMIN_API_URL = 'https://app.admin.elcsa.ru/api'
|
||||||
|
|
||||||
|
// In-memory admin access token — deliberately separate from the user `tokenStore`
|
||||||
|
// so the two independent auth systems never collide. No CSRF on the admin API.
|
||||||
|
let adminToken: string | null = null
|
||||||
|
|
||||||
|
export const adminTokenStore = {
|
||||||
|
get: () => adminToken,
|
||||||
|
set: (token: string) => { adminToken = token },
|
||||||
|
clear: () => { adminToken = null },
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doAdminRequest<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit,
|
||||||
|
allowRetry: boolean,
|
||||||
|
): Promise<T> {
|
||||||
|
const bearer = adminTokenStore.get()
|
||||||
|
|
||||||
|
const res = await fetch(`${ADMIN_API_URL}${path}`, {
|
||||||
|
...options,
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 401 && allowRetry) {
|
||||||
|
try {
|
||||||
|
await refreshAdminToken()
|
||||||
|
return doAdminRequest<T>(path, options, false)
|
||||||
|
} catch {
|
||||||
|
adminTokenStore.clear()
|
||||||
|
throw new Error('Unauthorized')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
if (!res.ok) throw data
|
||||||
|
return data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh by analogy with the main auth service: HttpOnly refresh cookie -> fresh access token.
|
||||||
|
// Exact path is isolated here — adjust if the backend differs (e.g. /v1/jwt/refresh).
|
||||||
|
export async function refreshAdminToken(): Promise<string> {
|
||||||
|
const res = await fetch(`${ADMIN_API_URL}/v1/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Unauthorized')
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.access_token) adminTokenStore.set(data.access_token)
|
||||||
|
return (data.access_token ?? true) as string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminLogin(payload: AdminLoginRequest): Promise<AdminLoginResponse> {
|
||||||
|
const data = await doAdminRequest<AdminLoginResponse>(
|
||||||
|
'/v1/auth/login',
|
||||||
|
{ method: 'POST', body: JSON.stringify(payload) },
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
if (data.access_token) adminTokenStore.set(data.access_token)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminMe(): Promise<AdminMeResponse> {
|
||||||
|
return doAdminRequest<AdminMeResponse>('/v1/auth/me', {}, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminLogout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await doAdminRequest<unknown>('/v1/auth/logout', { method: 'POST' }, false)
|
||||||
|
} finally {
|
||||||
|
adminTokenStore.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrganizations(limit = 50, offset = 0): Promise<OrganizationListResponse> {
|
||||||
|
return doAdminRequest<OrganizationListResponse>(
|
||||||
|
`/v1/organizations?limit=${limit}&offset=${offset}`,
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOrganization(payload: CreateOrganizationRequest): Promise<Organization> {
|
||||||
|
return doAdminRequest<Organization>(
|
||||||
|
'/v1/organizations',
|
||||||
|
{ method: 'POST', body: JSON.stringify(payload) },
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
16
src/features/admin/hooks/useAdminAuth.ts
Normal file
16
src/features/admin/hooks/useAdminAuth.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { refreshAdminToken } from '../api/adminApi'
|
||||||
|
|
||||||
|
export const ADMIN_AUTH_QUERY_KEY = ['admin-auth']
|
||||||
|
|
||||||
|
export function useAdminAuth(): { isAuthenticated: boolean; isLoading: boolean } {
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ADMIN_AUTH_QUERY_KEY,
|
||||||
|
queryFn: refreshAdminToken,
|
||||||
|
retry: false,
|
||||||
|
staleTime: Infinity,
|
||||||
|
gcTime: Infinity,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
})
|
||||||
|
return { isAuthenticated: !!data && !isError, isLoading }
|
||||||
|
}
|
||||||
13
src/features/admin/hooks/useAdminLogin.ts
Normal file
13
src/features/admin/hooks/useAdminLogin.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { adminLogin } from '../api/adminApi'
|
||||||
|
import { ADMIN_AUTH_QUERY_KEY } from './useAdminAuth'
|
||||||
|
|
||||||
|
export function useAdminLogin() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: adminLogin,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ADMIN_AUTH_QUERY_KEY })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/features/admin/hooks/useAdminLogout.ts
Normal file
13
src/features/admin/hooks/useAdminLogout.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { adminLogout } from '../api/adminApi'
|
||||||
|
import { ADMIN_AUTH_QUERY_KEY } from './useAdminAuth'
|
||||||
|
|
||||||
|
export function useAdminLogout() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: adminLogout,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ADMIN_AUTH_QUERY_KEY })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/features/admin/hooks/useCreateOrganization.ts
Normal file
13
src/features/admin/hooks/useCreateOrganization.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { createOrganization } from '../api/adminApi'
|
||||||
|
import { ORGANIZATIONS_QUERY_KEY } from './useOrganizations'
|
||||||
|
|
||||||
|
export function useCreateOrganization() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createOrganization,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ORGANIZATIONS_QUERY_KEY })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
11
src/features/admin/hooks/useOrganizations.ts
Normal file
11
src/features/admin/hooks/useOrganizations.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getOrganizations } from '../api/adminApi'
|
||||||
|
|
||||||
|
export const ORGANIZATIONS_QUERY_KEY = ['admin-organizations']
|
||||||
|
|
||||||
|
export function useOrganizations() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ORGANIZATIONS_QUERY_KEY,
|
||||||
|
queryFn: () => getOrganizations(),
|
||||||
|
})
|
||||||
|
}
|
||||||
23
src/features/admin/index.ts
Normal file
23
src/features/admin/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export {
|
||||||
|
adminLogin,
|
||||||
|
adminLogout,
|
||||||
|
getAdminMe,
|
||||||
|
getOrganizations,
|
||||||
|
createOrganization,
|
||||||
|
refreshAdminToken,
|
||||||
|
adminTokenStore,
|
||||||
|
} from './api/adminApi'
|
||||||
|
export type {
|
||||||
|
AdminLoginRequest,
|
||||||
|
AdminLoginResponse,
|
||||||
|
AdminMeResponse,
|
||||||
|
Organization,
|
||||||
|
OrganizationListResponse,
|
||||||
|
CreateOrganizationRequest,
|
||||||
|
BankDetails,
|
||||||
|
} from './model/types'
|
||||||
|
export { useAdminAuth, ADMIN_AUTH_QUERY_KEY } from './hooks/useAdminAuth'
|
||||||
|
export { useAdminLogin } from './hooks/useAdminLogin'
|
||||||
|
export { useAdminLogout } from './hooks/useAdminLogout'
|
||||||
|
export { useOrganizations, ORGANIZATIONS_QUERY_KEY } from './hooks/useOrganizations'
|
||||||
|
export { useCreateOrganization } from './hooks/useCreateOrganization'
|
||||||
67
src/features/admin/model/types.ts
Normal file
67
src/features/admin/model/types.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
export interface AdminLoginRequest {
|
||||||
|
login: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminLoginResponse {
|
||||||
|
access_token: string
|
||||||
|
token_type: string
|
||||||
|
id: string
|
||||||
|
login: string
|
||||||
|
first_name: string | null
|
||||||
|
last_name: string | null
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminMeResponse {
|
||||||
|
id: string
|
||||||
|
login: string
|
||||||
|
first_name: string | null
|
||||||
|
last_name: string | null
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BankDetails = Record<string, unknown>
|
||||||
|
|
||||||
|
export interface Organization {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
name: string
|
||||||
|
short_name: string | null
|
||||||
|
inn: string
|
||||||
|
ogrn: string | null
|
||||||
|
kpp: string | null
|
||||||
|
legal_address: string | null
|
||||||
|
actual_address: string | null
|
||||||
|
bank_details: BankDetails | null
|
||||||
|
contact_person: string | null
|
||||||
|
contact_phone: string | null
|
||||||
|
status: string
|
||||||
|
kyc_verified: boolean
|
||||||
|
kyc_verified_at: string | null
|
||||||
|
has_wallets: boolean
|
||||||
|
created_by: string | null
|
||||||
|
created_at: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrganizationListResponse {
|
||||||
|
items: Organization[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrganizationRequest {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
name: string
|
||||||
|
inn: string
|
||||||
|
short_name?: string | null
|
||||||
|
ogrn?: string | null
|
||||||
|
kpp?: string | null
|
||||||
|
legal_address?: string | null
|
||||||
|
actual_address?: string | null
|
||||||
|
bank_details?: BankDetails | null
|
||||||
|
contact_person?: string | null
|
||||||
|
contact_phone?: string | null
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
3106
src/openapi.json
Normal file
3106
src/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
1
src/pages/admin/index.ts
Normal file
1
src/pages/admin/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AdminPage } from './ui/AdminPage'
|
||||||
84
src/pages/admin/ui/AdminPage.module.css
Normal file
84
src/pages/admin/ui/AdminPage.module.css
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
padding: 40px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greeting {
|
||||||
|
font-size: clamp(28px, 4vw, 40px);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.1));
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
|
||||||
|
border-radius: 10px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout:hover {
|
||||||
|
background: rgba(255, 90, 90, 0.12);
|
||||||
|
border-color: rgba(255, 90, 90, 0.3);
|
||||||
|
color: #ff5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addBtn {
|
||||||
|
background: linear-gradient(135deg, var(--grad-edge, #4a6dff), var(--grad-center, #6f4aff));
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addBtn:hover {
|
||||||
|
filter: brightness(1.12);
|
||||||
|
box-shadow: 0 6px 20px rgba(74, 109, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page {
|
||||||
|
padding: 28px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/pages/admin/ui/AdminPage.tsx
Normal file
53
src/pages/admin/ui/AdminPage.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useAdminAuth, useAdminLogout } from '@features/admin'
|
||||||
|
import { Notification } from '@shared/ui'
|
||||||
|
import { AdminLoginForm } from '@widgets/admin-login-form'
|
||||||
|
import { LegalEntitiesTable } from '@widgets/legal-entities-table'
|
||||||
|
import { AddLegalEntityModal } from '@widgets/add-legal-entity-modal'
|
||||||
|
import styles from './AdminPage.module.css'
|
||||||
|
|
||||||
|
export function AdminPage() {
|
||||||
|
const { isAuthenticated, isLoading } = useAdminAuth()
|
||||||
|
const logout = useAdminLogout()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [notification, setNotification] = useState<{ message: string; status: 'success' | 'error' } | null>(null)
|
||||||
|
|
||||||
|
if (isLoading) return null
|
||||||
|
if (!isAuthenticated) return <AdminLoginForm />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<h1 className={styles.greeting}>Привет, Марк!</h1>
|
||||||
|
<button className={styles.logout} type="button" onClick={() => logout.mutate()}>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className={styles.content}>
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<h2 className={styles.sectionTitle}>Юридические лица</h2>
|
||||||
|
<button className={styles.addBtn} type="button" onClick={() => setModalOpen(true)}>
|
||||||
|
+ Добавить юридическое лицо
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LegalEntitiesTable />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<AddLegalEntityModal
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
onCreated={() => setNotification({ status: 'success', message: 'Юридическое лицо добавлено' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{notification && (
|
||||||
|
<Notification
|
||||||
|
status={notification.status}
|
||||||
|
message={notification.message}
|
||||||
|
onClose={() => setNotification(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,4 +19,5 @@ export const ROUTES = {
|
|||||||
SOGLASIE_PERSONALNYH_DANNYH: '/soglasie-personalnyh-dannyh',
|
SOGLASIE_PERSONALNYH_DANNYH: '/soglasie-personalnyh-dannyh',
|
||||||
REESTR_PD_RKN: '/reestr-pd-rkn',
|
REESTR_PD_RKN: '/reestr-pd-rkn',
|
||||||
TRANSACTIONS: '/transactions',
|
TRANSACTIONS: '/transactions',
|
||||||
|
ADMIN: '/sys-c7f29a4e-d81b-4630-ops-console',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
1
src/widgets/add-legal-entity-modal/index.ts
Normal file
1
src/widgets/add-legal-entity-modal/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AddLegalEntityModal } from './ui/AddLegalEntityModal'
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useCreateOrganization } from '@features/admin'
|
||||||
|
import type { CreateOrganizationRequest } from '@features/admin'
|
||||||
|
|
||||||
|
const INITIAL = {
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
name: '',
|
||||||
|
inn: '',
|
||||||
|
short_name: '',
|
||||||
|
ogrn: '',
|
||||||
|
kpp: '',
|
||||||
|
legal_address: '',
|
||||||
|
actual_address: '',
|
||||||
|
contact_person: '',
|
||||||
|
contact_phone: '',
|
||||||
|
status: 'active',
|
||||||
|
bank_name: '',
|
||||||
|
bik: '',
|
||||||
|
account: '',
|
||||||
|
corr_account: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormState = typeof INITIAL
|
||||||
|
|
||||||
|
function extractErrorMessage(error: unknown): string {
|
||||||
|
const e = error as { detail?: unknown }
|
||||||
|
if (typeof e?.detail === 'string') return e.detail
|
||||||
|
if (Array.isArray(e?.detail) && (e.detail[0] as { msg?: string })?.msg) {
|
||||||
|
return (e.detail[0] as { msg: string }).msg
|
||||||
|
}
|
||||||
|
return 'Не удалось добавить юридическое лицо'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddLegalEntityForm(onSuccess: () => void) {
|
||||||
|
const [form, setForm] = useState<FormState>(INITIAL)
|
||||||
|
const mutation = useCreateOrganization()
|
||||||
|
|
||||||
|
const setField = (key: keyof FormState) => (value: string) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }))
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const trimmedOrNull = (v: string) => (v.trim() ? v.trim() : null)
|
||||||
|
|
||||||
|
const bankEntries: Record<string, string> = {}
|
||||||
|
if (form.bank_name.trim()) bankEntries.bank_name = form.bank_name.trim()
|
||||||
|
if (form.bik.trim()) bankEntries.bik = form.bik.trim()
|
||||||
|
if (form.account.trim()) bankEntries.account = form.account.trim()
|
||||||
|
if (form.corr_account.trim()) bankEntries.corr_account = form.corr_account.trim()
|
||||||
|
|
||||||
|
const payload: CreateOrganizationRequest = {
|
||||||
|
email: form.email.trim(),
|
||||||
|
password: form.password,
|
||||||
|
name: form.name.trim(),
|
||||||
|
inn: form.inn.trim(),
|
||||||
|
short_name: trimmedOrNull(form.short_name),
|
||||||
|
ogrn: trimmedOrNull(form.ogrn),
|
||||||
|
kpp: trimmedOrNull(form.kpp),
|
||||||
|
legal_address: trimmedOrNull(form.legal_address),
|
||||||
|
actual_address: trimmedOrNull(form.actual_address),
|
||||||
|
contact_person: trimmedOrNull(form.contact_person),
|
||||||
|
contact_phone: trimmedOrNull(form.contact_phone),
|
||||||
|
bank_details: Object.keys(bankEntries).length ? bankEntries : null,
|
||||||
|
status: form.status.trim() || 'active',
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation.mutate(payload, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setForm(INITIAL)
|
||||||
|
onSuccess()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = mutation.isError ? extractErrorMessage(mutation.error) : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
setField,
|
||||||
|
handleSubmit,
|
||||||
|
isLoading: mutation.isPending,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
@keyframes dialogIn {
|
||||||
|
from { opacity: 0; transform: scale(0.96); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: var(--bg-mid, #151520);
|
||||||
|
border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.1));
|
||||||
|
border-radius: 20px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: dialogIn 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.4));
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn:hover {
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 16px 24px 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.5));
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 16px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupLabel:first-child {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff5a5a;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { FormField, PrimaryButton } from '@shared/ui'
|
||||||
|
import { useAddLegalEntityForm } from '../model/useAddLegalEntityForm'
|
||||||
|
import styles from './AddLegalEntityModal.module.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onCreated: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddLegalEntityModal({ open, onClose, onCreated }: Props) {
|
||||||
|
const { form, setField, handleSubmit, isLoading, error } = useAddLegalEntityForm(() => {
|
||||||
|
onCreated()
|
||||||
|
onClose()
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
function handleOverlay(e: React.MouseEvent<HTMLDivElement>) {
|
||||||
|
if (e.target === e.currentTarget) onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay} onMouseDown={handleOverlay}>
|
||||||
|
<div className={styles.dialog}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.title}>Добавить юридическое лицо</span>
|
||||||
|
<button className={styles.closeBtn} type="button" onClick={onClose} aria-label="Закрыть">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className={styles.body} onSubmit={handleSubmit}>
|
||||||
|
<p className={styles.groupLabel}>Обязательные поля</p>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
<FormField label="Email" type="email" value={form.email} onChange={setField('email')} placeholder="org@mail.ru" required />
|
||||||
|
<FormField label="Пароль" type="password" value={form.password} onChange={setField('password')} placeholder="Минимум 8 символов" required />
|
||||||
|
<FormField label="Наименование" value={form.name} onChange={setField('name')} placeholder="ООО «Ромашка»" required />
|
||||||
|
<FormField label="ИНН" value={form.inn} onChange={setField('inn')} placeholder="10–12 цифр" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={styles.groupLabel}>Дополнительные поля</p>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
<FormField label="Краткое наименование" value={form.short_name} onChange={setField('short_name')} placeholder="Ромашка" />
|
||||||
|
<FormField label="ОГРН" value={form.ogrn} onChange={setField('ogrn')} placeholder="—" />
|
||||||
|
<FormField label="КПП" value={form.kpp} onChange={setField('kpp')} placeholder="—" />
|
||||||
|
<FormField label="Статус" value={form.status} onChange={setField('status')} placeholder="active" />
|
||||||
|
<FormField label="Юридический адрес" value={form.legal_address} onChange={setField('legal_address')} placeholder="—" />
|
||||||
|
<FormField label="Фактический адрес" value={form.actual_address} onChange={setField('actual_address')} placeholder="—" />
|
||||||
|
<FormField label="Контактное лицо" value={form.contact_person} onChange={setField('contact_person')} placeholder="—" />
|
||||||
|
<FormField label="Контактный телефон" type="tel" value={form.contact_phone} onChange={setField('contact_phone')} placeholder="+7 (999) 000-00-00" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={styles.groupLabel}>Банковские реквизиты</p>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
<FormField label="Банк" value={form.bank_name} onChange={setField('bank_name')} placeholder="—" />
|
||||||
|
<FormField label="БИК" value={form.bik} onChange={setField('bik')} placeholder="—" />
|
||||||
|
<FormField label="Расчётный счёт" value={form.account} onChange={setField('account')} placeholder="—" />
|
||||||
|
<FormField label="Корр. счёт" value={form.corr_account} onChange={setField('corr_account')} placeholder="—" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<PrimaryButton label={isLoading ? 'Сохранение...' : 'Сохранить'} disabled={isLoading} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/widgets/admin-login-form/index.ts
Normal file
1
src/widgets/admin-login-form/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AdminLoginForm } from './ui/AdminLoginForm'
|
||||||
35
src/widgets/admin-login-form/model/useAdminLoginForm.ts
Normal file
35
src/widgets/admin-login-form/model/useAdminLoginForm.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useAdminLogin } from '@features/admin'
|
||||||
|
|
||||||
|
function extractErrorMessage(error: unknown): string {
|
||||||
|
const e = error as { detail?: unknown }
|
||||||
|
if (typeof e?.detail === 'string') return e.detail
|
||||||
|
if (Array.isArray(e?.detail) && (e.detail[0] as { msg?: string })?.msg) {
|
||||||
|
return (e.detail[0] as { msg: string }).msg
|
||||||
|
}
|
||||||
|
return 'Неверный логин или пароль'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminLoginForm() {
|
||||||
|
const [login, setLogin] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const loginMutation = useAdminLogin()
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!login || !password) return
|
||||||
|
loginMutation.mutate({ login, password })
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = loginMutation.isError ? extractErrorMessage(loginMutation.error) : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
login,
|
||||||
|
setLogin,
|
||||||
|
password,
|
||||||
|
setPassword,
|
||||||
|
isLoading: loginMutation.isPending,
|
||||||
|
error,
|
||||||
|
handleSubmit,
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/widgets/admin-login-form/ui/AdminLoginForm.module.css
Normal file
49
src/widgets/admin-login-form/ui/AdminLoginForm.module.css
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
.wrap {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--glass-bg, rgba(255, 255, 255, 0.06));
|
||||||
|
border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.1));
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 36px 32px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.5));
|
||||||
|
margin: 0 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff5a5a;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 14px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit {
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
40
src/widgets/admin-login-form/ui/AdminLoginForm.tsx
Normal file
40
src/widgets/admin-login-form/ui/AdminLoginForm.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { FormField, PrimaryButton } from '@shared/ui'
|
||||||
|
import { useAdminLoginForm } from '../model/useAdminLoginForm'
|
||||||
|
import styles from './AdminLoginForm.module.css'
|
||||||
|
|
||||||
|
export function AdminLoginForm() {
|
||||||
|
const { login, setLogin, password, setPassword, isLoading, error, handleSubmit } = useAdminLoginForm()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<form className={styles.card} onSubmit={handleSubmit}>
|
||||||
|
<h1 className={styles.title}>Панель администратора</h1>
|
||||||
|
<p className={styles.subtitle}>Войдите, чтобы продолжить</p>
|
||||||
|
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<FormField
|
||||||
|
label="Логин"
|
||||||
|
value={login}
|
||||||
|
onChange={setLogin}
|
||||||
|
placeholder="Введите логин"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Пароль"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|
||||||
|
<div className={styles.submit}>
|
||||||
|
<PrimaryButton label={isLoading ? 'Вход...' : 'Войти'} disabled={isLoading} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/widgets/legal-entities-table/index.ts
Normal file
1
src/widgets/legal-entities-table/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { LegalEntitiesTable } from './ui/LegalEntitiesTable'
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
.tableWrap {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0 16px 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
vertical-align: middle;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr:hover td {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: 600;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subname {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(74, 109, 255, 0.12);
|
||||||
|
color: #7c95ff;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kyc {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kycOk {
|
||||||
|
background: rgba(0, 196, 140, 0.14);
|
||||||
|
color: #00c48c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kycNo {
|
||||||
|
background: rgba(255, 90, 90, 0.14);
|
||||||
|
color: #ff5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
padding: 40px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
81
src/widgets/legal-entities-table/ui/LegalEntitiesTable.tsx
Normal file
81
src/widgets/legal-entities-table/ui/LegalEntitiesTable.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useOrganizations } from '@features/admin'
|
||||||
|
import styles from './LegalEntitiesTable.module.css'
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Активно',
|
||||||
|
blocked: 'Заблокировано',
|
||||||
|
inactive: 'Неактивно',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null): string {
|
||||||
|
if (!value) return '—'
|
||||||
|
const d = new Date(value)
|
||||||
|
if (Number.isNaN(d.getTime())) return '—'
|
||||||
|
return d.toLocaleDateString('ru-RU')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LegalEntitiesTable() {
|
||||||
|
const { data, isLoading, isError } = useOrganizations()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className={styles.tableWrap}><div className={styles.state}>Загрузка...</div></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className={styles.tableWrap}>
|
||||||
|
<div className={styles.state}>Не удалось загрузить список юридических лиц</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={styles.tableWrap}>
|
||||||
|
<div className={styles.state}>Юридические лица ещё не добавлены</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tableWrap}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>ИНН</th>
|
||||||
|
<th>КПП</th>
|
||||||
|
<th>Контактное лицо</th>
|
||||||
|
<th>Телефон</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>KYC</th>
|
||||||
|
<th>Создано</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.items.map((org) => (
|
||||||
|
<tr key={org.id}>
|
||||||
|
<td>
|
||||||
|
<span className={styles.name}>{org.name}</span>
|
||||||
|
{org.short_name && <span className={styles.subname}>{org.short_name}</span>}
|
||||||
|
</td>
|
||||||
|
<td className={styles.mono}>{org.inn}</td>
|
||||||
|
<td className={styles.mono}>{org.kpp ?? '—'}</td>
|
||||||
|
<td>{org.contact_person ?? '—'}</td>
|
||||||
|
<td className={styles.mono}>{org.contact_phone ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
<span className={styles.status}>{STATUS_LABELS[org.status] ?? org.status}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`${styles.kyc} ${org.kyc_verified ? styles.kycOk : styles.kycNo}`}>
|
||||||
|
{org.kyc_verified ? 'Да' : 'Нет'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{formatDate(org.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
src/widgets/register-form/ui/IndividualForm.tsx
Normal file
84
src/widgets/register-form/ui/IndividualForm.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { FormField } from '@shared/ui'
|
||||||
|
import { PrimaryButton } from '@shared/ui'
|
||||||
|
import { Button } from '@shared/ui'
|
||||||
|
import { useRegisterForm } from '../model/useRegisterForm'
|
||||||
|
import styles from './RegisterForm.module.css'
|
||||||
|
|
||||||
|
export function IndividualForm() {
|
||||||
|
const {
|
||||||
|
email, setEmail,
|
||||||
|
password, setPassword,
|
||||||
|
confirmPassword, setConfirmPassword,
|
||||||
|
verificationCode, setVerificationCode,
|
||||||
|
codeSent,
|
||||||
|
isLoadingCode,
|
||||||
|
isLoadingSubmit,
|
||||||
|
error,
|
||||||
|
handleRequestCode,
|
||||||
|
handleSubmit,
|
||||||
|
} = useRegisterForm()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className={styles.twoCol}>
|
||||||
|
<div className={styles.leftCol}>
|
||||||
|
<FormField
|
||||||
|
label="Введите адрес электронной почты"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={setEmail}
|
||||||
|
placeholder="example@mail.ru"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Придумайте пароль"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Повторите пароль"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={setConfirmPassword}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.rightCol}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
type="button"
|
||||||
|
onClick={handleRequestCode}
|
||||||
|
disabled={codeSent || isLoadingCode}
|
||||||
|
>
|
||||||
|
{isLoadingCode ? 'Отправка...' : codeSent ? 'Код отправлен' : 'Получить проверочный код'}
|
||||||
|
</Button>
|
||||||
|
<span className={styles.codeHint}>Код не пришёл</span>
|
||||||
|
<FormField
|
||||||
|
label="Ввести код"
|
||||||
|
type="text"
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={setVerificationCode}
|
||||||
|
placeholder="000 000"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|
||||||
|
<div className={styles.submitWrapper}>
|
||||||
|
<PrimaryButton label={isLoadingSubmit ? 'Создание...' : 'Создать'} disabled={isLoadingSubmit} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={styles.legal}>
|
||||||
|
Нажимая «Создать», вы принимаете<br />
|
||||||
|
<a href="#">Пользовательское соглашение</a> и <a href="#">Политику конфиденциальности</a>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
src/widgets/register-form/ui/LegalRegisterInfo.tsx
Normal file
59
src/widgets/register-form/ui/LegalRegisterInfo.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { PrimaryButton } from '@shared/ui'
|
||||||
|
import styles from './RegisterForm.module.css'
|
||||||
|
|
||||||
|
const DOCS_EMAIL = 'company@elcsa.ru'
|
||||||
|
|
||||||
|
const REQUIRED_DOCUMENTS = [
|
||||||
|
'Устав организации в действующей редакции',
|
||||||
|
'Решение (протокол) о создании организации и о назначении руководителя',
|
||||||
|
'Выписка по расчётному счёту из банка за последние шесть месяцев',
|
||||||
|
'Выписка из Единого государственного реестра юридических лиц (ЕГРЮЛ)',
|
||||||
|
'Идентификатор электронного документооборота (ЭДО)',
|
||||||
|
'Реквизиты расчётного счёта: номер Р/С, БИК и наименование банка',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function LegalRegisterInfo() {
|
||||||
|
const [sent, setSent] = useState(false)
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<div className={styles.legalDone}>
|
||||||
|
<h2 className={styles.legalDoneTitle}>Спасибо!</h2>
|
||||||
|
<p className={styles.legalDoneText}>
|
||||||
|
Мы получили уведомление об отправке документов. После проверки мы свяжемся с вами
|
||||||
|
по указанному адресу электронной почты.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.legalInfo}>
|
||||||
|
<p className={styles.legalIntro}>
|
||||||
|
Для регистрации юридического лица отправьте перечисленные ниже документы на нашу
|
||||||
|
электронную почту. После проверки мы свяжемся с вами для завершения регистрации.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={styles.docsBlock}>
|
||||||
|
<span className={styles.docsLabel}>Необходимые документы</span>
|
||||||
|
<ul className={styles.docsList}>
|
||||||
|
{REQUIRED_DOCUMENTS.map((doc) => (
|
||||||
|
<li key={doc}>{doc}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.emailBlock}>
|
||||||
|
<span className={styles.docsLabel}>Адрес для отправки документов</span>
|
||||||
|
<a href={`mailto:${DOCS_EMAIL}`} className={styles.emailLink}>
|
||||||
|
{DOCS_EMAIL}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.submitWrapper}>
|
||||||
|
<PrimaryButton label="Документы отправлены" type="button" onClick={() => setSent(true)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -26,6 +26,102 @@
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.typeSelect {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeSelect button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legalInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legalIntro {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsBlock,
|
||||||
|
.emailBlock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsList {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsList li {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emailLink {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--interactive);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emailLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legalDone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legalDoneTitle {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legalDoneText {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.twoCol {
|
.twoCol {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -1,90 +1,39 @@
|
|||||||
import { FormField } from '@shared/ui'
|
import { useState } from 'react'
|
||||||
import { PrimaryButton } from '@shared/ui'
|
|
||||||
import { Button } from '@shared/ui'
|
import { Button } from '@shared/ui'
|
||||||
import logo from '@shared/assets/logo-full-white.png'
|
import logo from '@shared/assets/logo-full-white.png'
|
||||||
import { useRegisterForm } from '../model/useRegisterForm'
|
import { IndividualForm } from './IndividualForm'
|
||||||
|
import { LegalRegisterInfo } from './LegalRegisterInfo'
|
||||||
import styles from './RegisterForm.module.css'
|
import styles from './RegisterForm.module.css'
|
||||||
|
|
||||||
|
type RegisterType = 'individual' | 'legal'
|
||||||
|
|
||||||
export function RegisterForm() {
|
export function RegisterForm() {
|
||||||
const {
|
const [type, setType] = useState<RegisterType | null>(null)
|
||||||
email, setEmail,
|
|
||||||
password, setPassword,
|
|
||||||
confirmPassword, setConfirmPassword,
|
|
||||||
verificationCode, setVerificationCode,
|
|
||||||
codeSent,
|
|
||||||
isLoadingCode,
|
|
||||||
isLoadingSubmit,
|
|
||||||
error,
|
|
||||||
handleRequestCode,
|
|
||||||
handleSubmit,
|
|
||||||
} = useRegisterForm()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className={styles.card} onSubmit={handleSubmit}>
|
<div className={styles.card}>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<img src={logo} alt="ЭКСА" />
|
<img src={logo} alt="ЭКСА" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className={styles.title}>Создать кошелёк ЭКСА</h1>
|
<h1 className={styles.title}>Создать кошелёк ЭКСА</h1>
|
||||||
|
|
||||||
<div className={styles.twoCol}>
|
{type === null ? (
|
||||||
<div className={styles.leftCol}>
|
<div className={styles.typeSelect}>
|
||||||
<FormField
|
<Button variant="primary" onClick={() => setType('individual')}>
|
||||||
label="Введите адрес электронной почты"
|
Зарегистрироваться как физическое лицо
|
||||||
type="email"
|
</Button>
|
||||||
value={email}
|
<Button variant="outline" onClick={() => setType('legal')}>
|
||||||
onChange={setEmail}
|
Зарегистрироваться как юридическое лицо
|
||||||
placeholder="example@mail.ru"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Придумайте пароль"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={setPassword}
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Повторите пароль"
|
|
||||||
type="password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={setConfirmPassword}
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.rightCol}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
type="button"
|
|
||||||
onClick={handleRequestCode}
|
|
||||||
disabled={codeSent || isLoadingCode}
|
|
||||||
>
|
|
||||||
{isLoadingCode ? 'Отправка...' : codeSent ? 'Код отправлен' : 'Получить проверочный код'}
|
|
||||||
</Button>
|
</Button>
|
||||||
<span className={styles.codeHint}>Код не пришёл</span>
|
|
||||||
<FormField
|
|
||||||
label="Ввести код"
|
|
||||||
type="text"
|
|
||||||
value={verificationCode}
|
|
||||||
onChange={setVerificationCode}
|
|
||||||
placeholder="000 000"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
{error && <p className={styles.error}>{error}</p>}
|
<button type="button" className={styles.back} onClick={() => setType(null)}>
|
||||||
|
← Назад к выбору
|
||||||
<div className={styles.submitWrapper}>
|
</button>
|
||||||
<PrimaryButton label={isLoadingSubmit ? 'Создание...' : 'Создать'} disabled={isLoadingSubmit} />
|
{type === 'individual' ? <IndividualForm /> : <LegalRegisterInfo />}
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
<p className={styles.legal}>
|
</div>
|
||||||
Нажимая «Создать», вы принимаете<br />
|
|
||||||
<a href="#">Пользовательское соглашение</a> и <a href="#">Политику конфиденциальности</a>
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user