profile refactor
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useIsAuthenticated } from '@features/auth'
|
||||
import { ROUTES } from '@shared/config/routes'
|
||||
import { Spinner } from '@shared/ui'
|
||||
|
||||
export function GuestRoute() {
|
||||
const { isAuthenticated, isLoading } = useIsAuthenticated()
|
||||
const location = useLocation()
|
||||
const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? ROUTES.WALLET
|
||||
|
||||
if (isLoading) return null
|
||||
if (isLoading) return <Spinner overlay size="lg" label="Загрузка" />
|
||||
if (isAuthenticated) return <Navigate to={from} replace />
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useIsAuthenticated } from '@features/auth'
|
||||
import { ROUTES } from '@shared/config/routes'
|
||||
import { Spinner } from '@shared/ui'
|
||||
|
||||
export function ProtectedRoute() {
|
||||
const { isAuthenticated, isLoading } = useIsAuthenticated()
|
||||
const location = useLocation()
|
||||
|
||||
if (isLoading) return null
|
||||
if (isLoading) return <Spinner overlay size="lg" label="Загрузка" />
|
||||
if (!isAuthenticated) return <Navigate to={ROUTES.LOGIN} state={{ from: location }} replace />
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SoglasiePage } from '@pages/soglasie-personalnyh-dannyh'
|
||||
import { ReestryPage } from '@pages/reestr-pd-rkn'
|
||||
import { StakingPage } from '@pages/staking'
|
||||
import { PoolsPage } from '@pages/pools'
|
||||
import { NotFoundPage } from '@pages/not-found'
|
||||
import { WalletLayout } from '@widgets/wallet-layout'
|
||||
import { ROUTES } from '@shared/config/routes'
|
||||
import { ScrollToTop } from './ScrollToTop'
|
||||
@@ -73,6 +74,8 @@ export function RouterProvider() {
|
||||
<Route path={ROUTES.SEED_PHRASE} element={<SeedPhrasePage />} />
|
||||
<Route path={ROUTES.KYC} element={<KycPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
1
src/pages/not-found/index.ts
Normal file
1
src/pages/not-found/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { NotFoundPage } from './ui/NotFoundPage'
|
||||
141
src/pages/not-found/ui/NotFoundPage.module.css
Normal file
141
src/pages/not-found/ui/NotFoundPage.module.css
Normal file
@@ -0,0 +1,141 @@
|
||||
.page {
|
||||
position: relative;
|
||||
min-height: 70vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Falling coins (decorative background) ---- */
|
||||
.rain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.drop {
|
||||
position: absolute;
|
||||
top: -80px;
|
||||
opacity: 0.55;
|
||||
animation-name: fall;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
filter: drop-shadow(0 6px 16px rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
|
||||
@keyframes fall {
|
||||
0% {
|
||||
transform: translateY(-80px) rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
90% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(85vh) rotate(360deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Foreground content ---- */
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.code {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.digit {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 160px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
text-shadow: 0 8px 40px rgba(74, 109, 255, 0.4);
|
||||
}
|
||||
|
||||
/* The middle "0" is a coin that flips like it's spinning in the air. */
|
||||
.coin {
|
||||
display: inline-flex;
|
||||
animation: flip 2.6s ease-in-out infinite;
|
||||
transform-style: preserve-3d;
|
||||
filter: drop-shadow(0 12px 30px rgba(247, 147, 26, 0.45));
|
||||
}
|
||||
|
||||
@keyframes flip {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotateY(0deg) translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: rotateY(180deg) translateY(-16px);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 36px 0 12px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0 0 32px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 14px 32px;
|
||||
border-radius: 12px;
|
||||
background: var(--interactive);
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--highlight);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 28px rgba(0, 212, 255, 0.35);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.digit {
|
||||
font-size: 96px;
|
||||
}
|
||||
|
||||
.coin > :global(div) {
|
||||
width: 88px !important;
|
||||
height: 88px !important;
|
||||
font-size: 40px !important;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.drop,
|
||||
.coin {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
78
src/pages/not-found/ui/NotFoundPage.tsx
Normal file
78
src/pages/not-found/ui/NotFoundPage.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Header } from '@widgets/header'
|
||||
import { Footer } from '@widgets/footer'
|
||||
import { TokenIcon } from '@shared/ui/TokenIcon'
|
||||
import { ROUTES } from '@shared/config/routes'
|
||||
import styles from './NotFoundPage.module.css'
|
||||
|
||||
/** Decorative coin set — purely visual, no live rates. */
|
||||
const COINS = [
|
||||
{ letter: '₿', color: '#f7931a' },
|
||||
{ letter: 'Ξ', color: '#627eea' },
|
||||
{ letter: '₮', color: '#26a17b' },
|
||||
{ letter: '◎', color: '#9945ff' },
|
||||
{ letter: '$', color: '#2775ca' },
|
||||
{ letter: '₽', color: '#4a6dff' },
|
||||
{ letter: 'Ʉ', color: '#00d4ff' },
|
||||
]
|
||||
|
||||
/** Pre-baked rain layout so the scene looks the same on every render. */
|
||||
const RAIN = [
|
||||
{ left: 6, size: 34, delay: 0, duration: 7.5 },
|
||||
{ left: 18, size: 26, delay: 2.1, duration: 9 },
|
||||
{ left: 29, size: 44, delay: 4.3, duration: 6.5 },
|
||||
{ left: 41, size: 22, delay: 1.2, duration: 10 },
|
||||
{ left: 54, size: 38, delay: 3.4, duration: 8 },
|
||||
{ left: 67, size: 28, delay: 0.6, duration: 9.5 },
|
||||
{ left: 78, size: 48, delay: 2.8, duration: 7 },
|
||||
{ left: 88, size: 24, delay: 5, duration: 8.5 },
|
||||
{ left: 95, size: 32, delay: 1.7, duration: 9.2 },
|
||||
]
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className={styles.page}>
|
||||
<div className={styles.rain} aria-hidden="true">
|
||||
{RAIN.map((coin, i) => {
|
||||
const token = COINS[i % COINS.length]
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={styles.drop}
|
||||
style={{
|
||||
left: `${coin.left}%`,
|
||||
animationDelay: `${coin.delay}s`,
|
||||
animationDuration: `${coin.duration}s`,
|
||||
}}
|
||||
>
|
||||
<TokenIcon letter={token.letter} color={token.color} size={coin.size} />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.code} aria-hidden="true">
|
||||
<span className={styles.digit}>4</span>
|
||||
<span className={styles.coin}>
|
||||
<TokenIcon letter="₿" color="#f7931a" size={140} />
|
||||
</span>
|
||||
<span className={styles.digit}>4</span>
|
||||
</div>
|
||||
|
||||
<h1 className={styles.title}>Такой страницы не существует</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Похоже, курс этого маршрута обнулился. Проверьте адрес или вернитесь на главную.
|
||||
</p>
|
||||
|
||||
<Link to={ROUTES.HOME} className={styles.button}>
|
||||
На главную
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,24 @@
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg-deep);
|
||||
animation: overlayFadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes overlayFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
|
||||
@@ -9,11 +9,18 @@ interface Props {
|
||||
label?: string
|
||||
/** Растянуть на всю высоту контейнера и отцентрировать содержимое. */
|
||||
fullscreen?: boolean
|
||||
/** Зафиксировать поверх всего экрана с подложкой (для загрузки на весь вьюпорт). */
|
||||
overlay?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Spinner({ size = 'md', label, fullscreen, className }: Props) {
|
||||
const wrapClass = [styles.wrap, fullscreen ? styles.fullscreen : '', className ?? '']
|
||||
export function Spinner({ size = 'md', label, fullscreen, overlay, className }: Props) {
|
||||
const wrapClass = [
|
||||
styles.wrap,
|
||||
fullscreen ? styles.fullscreen : '',
|
||||
overlay ? styles.overlay : '',
|
||||
className ?? '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
|
||||
@@ -150,104 +150,91 @@
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
z-index: 1100; /* выше полноэкранного меню — крестик всегда сверху */
|
||||
}
|
||||
|
||||
/* Все три полоски позиционируются по одной модели (top: 50% + translate),
|
||||
чтобы рендер был идентичным и они не выглядели по-разному. */
|
||||
.burger span {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--text-primary);
|
||||
transition: transform 0.3s ease, opacity 0.2s ease, top 0.3s ease;
|
||||
transition: transform 0.3s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.burger span:nth-child(1) {
|
||||
top: 2px;
|
||||
transform: translateY(-50%) translateY(-8px);
|
||||
}
|
||||
|
||||
.burger span:nth-child(2) {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.burger span:nth-child(3) {
|
||||
top: 18px;
|
||||
transform: translateY(-50%) translateY(8px);
|
||||
}
|
||||
|
||||
.burgerOpen span:nth-child(1) {
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.burgerOpen span:nth-child(2) {
|
||||
transform: translateY(-50%) scaleX(0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.burgerOpen span:nth-child(3) {
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
/* ── Мобильное меню ───────────────────────────────────────── */
|
||||
.mobileOverlay {
|
||||
/* ── Мобильное меню (на весь экран, поверх шапки) ──────────── */
|
||||
.mobileMenu {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 90;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 80px 24px 40px;
|
||||
background: var(--bg-deep);
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.mobileOverlayOpen {
|
||||
.mobileMenuOpen {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.mobileMenu {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(78vw, 320px);
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 24px 20px;
|
||||
background: var(--bg-mid, #1b1547);
|
||||
border-left: 1px solid var(--glass-border);
|
||||
z-index: 95;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.mobileMenuOpen {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.mobileLink {
|
||||
display: block;
|
||||
padding: 14px 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
padding: 16px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
transform: translateY(16px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease, background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.mobileMenuOpen .mobileLink {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.mobileLink:hover {
|
||||
@@ -285,7 +272,6 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobileOverlay,
|
||||
.mobileMenu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -122,10 +122,6 @@ export function WalletHeader() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
className={`${styles.mobileOverlay} ${menuOpen ? styles.mobileOverlayOpen : ''}`}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
<div className={`${styles.mobileMenu} ${menuOpen ? styles.mobileMenuOpen : ''}`}>
|
||||
{NAV_ITEMS.map((item, i) => (
|
||||
<NavLink
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user