14.05.2026 rip

This commit is contained in:
2026-05-14 16:15:56 +03:00
parent f5562871c0
commit 9c3fbbdc4b
6 changed files with 602 additions and 4 deletions

View File

@@ -26,9 +26,9 @@ export function RouterProvider() {
</Route> </Route>
<Route path={ROUTES.CONVERTER} element={<ConverterPage />} /> <Route path={ROUTES.CONVERTER} element={<ConverterPage />} />
<Route path={ROUTES.WALLET} element={<WalletPage />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route path={ROUTES.WALLET} element={<WalletPage />} />
<Route path={ROUTES.SWAP} element={<SwapPage />} /> <Route path={ROUTES.SWAP} element={<SwapPage />} />
<Route path={ROUTES.PROFILE} element={<ProfilePage />} /> <Route path={ROUTES.PROFILE} element={<ProfilePage />} />
<Route path={ROUTES.SEED_PHRASE} element={<SeedPhrasePage />} /> <Route path={ROUTES.SEED_PHRASE} element={<SeedPhrasePage />} />

View File

@@ -0,0 +1,2 @@
export { SendModal } from './ui/SendModal'
export type { SendModalToken } from './ui/SendModal'

View File

@@ -0,0 +1,370 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(10, 11, 46, 0.8);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.dialog {
background: var(--bg-mid);
border: 1px solid var(--glass-border);
border-radius: 24px;
padding: 32px;
width: 100%;
max-width: 480px;
animation: dialogIn 0.18s ease;
}
@keyframes dialogIn {
from {
opacity: 0;
transform: scale(0.96) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* ─── Header ────────────────────────────────── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28px;
}
.title {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.close {
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
}
.close:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
/* ─── Selects row ────────────────────────────── */
.selectsRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 24px;
}
.selectGroup {
position: relative;
}
.selectLabel {
display: block;
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
margin-bottom: 8px;
}
.selectTrigger {
width: 100%;
height: 48px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 0 12px 0 14px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
font-family: inherit;
transition: border-color 0.15s, background 0.15s;
}
.selectTrigger:hover,
.selectTriggerOpen {
border-color: rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.06);
}
.selectValue {
flex: 1;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chevron {
color: var(--text-secondary);
flex-shrink: 0;
transition: transform 0.15s;
}
.chevronOpen {
transform: rotate(180deg);
}
/* ─── Token dot ─────────────────────────────── */
.tokenDot {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 10px;
font-weight: 700;
color: #fff;
}
.tokenDot img {
width: 18px;
height: 18px;
object-fit: contain;
}
/* ─── Speed dot ─────────────────────────────── */
.speedDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.speedDot_slow {
background: #f3ba2f;
box-shadow: 0 0 6px rgba(243, 186, 47, 0.6);
}
.speedDot_normal {
background: var(--interactive);
box-shadow: 0 0 6px rgba(74, 109, 255, 0.6);
}
.speedDot_fast {
background: var(--success);
box-shadow: 0 0 6px rgba(38, 161, 123, 0.6);
}
/* ─── Dropdown ──────────────────────────────── */
.dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: #1e1a4a;
border: 1px solid var(--glass-border);
border-radius: 12px;
overflow: hidden;
z-index: 10;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.dropdownItem {
width: 100%;
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
cursor: pointer;
transition: background 0.15s;
text-align: left;
}
.dropdownItem:hover {
background: var(--glass-bg);
}
.dropdownItemActive {
background: rgba(74, 109, 255, 0.15);
color: #7a9dff;
}
.dropdownTicker {
font-weight: 600;
font-size: 14px;
}
.dropdownName {
color: var(--text-secondary);
font-size: 12px;
flex: 1;
}
/* ─── Fields ────────────────────────────────── */
.field {
margin-bottom: 20px;
}
.fieldLabel {
display: block;
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
margin-bottom: 8px;
}
.input {
width: 100%;
height: 52px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 0 16px;
color: var(--text-primary);
font-size: 15px;
font-family: var(--font-mono);
outline: none;
transition: border-color 0.15s, background 0.15s;
box-sizing: border-box;
}
.input::placeholder {
color: rgba(255, 255, 255, 0.2);
font-family: var(--font-sans);
}
.input:focus {
border-color: var(--interactive);
background: rgba(74, 109, 255, 0.05);
}
.input[type='number']::-webkit-outer-spin-button,
.input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
}
.input[type='number'] {
-moz-appearance: textfield;
}
/* ─── Amount field ──────────────────────────── */
.amountWrap {
position: relative;
}
.amountInput {
padding-right: 64px;
}
.amountTicker {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
pointer-events: none;
user-select: none;
}
.maxHint {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-top: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.maxBtn {
background: none;
border: none;
padding: 0;
color: var(--interactive);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: color 0.15s;
}
.maxBtn:hover {
color: var(--highlight);
text-decoration: underline;
}
/* ─── Submit button ─────────────────────────── */
.submitBtn {
width: 100%;
height: 52px;
background: linear-gradient(135deg, #4a6dff 0%, #6b4fff 100%);
border: none;
border-radius: 12px;
color: #fff;
font-size: 16px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
margin-top: 4px;
}
.submitBtn:hover {
opacity: 0.88;
transform: translateY(-1px);
}
.submitBtn:active {
transform: translateY(0);
opacity: 1;
}
/* ─── Mobile ────────────────────────────────── */
@media (max-width: 520px) {
.dialog {
padding: 24px 20px;
border-radius: 20px;
}
.selectsRow {
grid-template-columns: 1fr;
gap: 10px;
}
}

View File

@@ -0,0 +1,205 @@
import { useState, useEffect } from 'react'
import styles from './SendModal.module.css'
export interface SendModalToken {
ticker: string
name: string
logo?: string
color: string
bal: string
}
interface Props {
open: boolean
onClose: () => void
tokens: SendModalToken[]
initialTicker?: string
}
type Speed = 'slow' | 'normal' | 'fast'
const SPEEDS: { value: Speed; label: string }[] = [
{ value: 'slow', label: 'Медленно' },
{ value: 'normal', label: 'Нормально' },
{ value: 'fast', label: 'Быстро' },
]
export function SendModal({ open, onClose, tokens, initialTicker }: Props) {
const [ticker, setTicker] = useState(initialTicker ?? tokens[0]?.ticker ?? '')
const [speed, setSpeed] = useState<Speed>('normal')
const [address, setAddress] = useState('')
const [amount, setAmount] = useState('')
const [openDropdown, setOpenDropdown] = useState<'token' | 'speed' | null>(null)
const token = tokens.find((t) => t.ticker === ticker) ?? tokens[0]
const speedLabel = SPEEDS.find((s) => s.value === speed)?.label ?? 'Нормально'
useEffect(() => {
if (initialTicker) setTicker(initialTicker)
}, [initialTicker])
useEffect(() => {
if (!open) {
setAddress('')
setAmount('')
setOpenDropdown(null)
return
}
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [open, onClose])
if (!open) return null
function handleOverlayClick() {
if (openDropdown) {
setOpenDropdown(null)
} else {
onClose()
}
}
return (
<div className={styles.overlay} onClick={handleOverlayClick}>
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
<div className={styles.header}>
<span className={styles.title}>Отправить</span>
<button className={styles.close} onClick={onClose} type="button" aria-label="Закрыть">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
<div className={styles.selectsRow}>
<div className={styles.selectGroup}>
<label className={styles.selectLabel}>Токен</label>
<button
type="button"
className={`${styles.selectTrigger} ${openDropdown === 'token' ? styles.selectTriggerOpen : ''}`}
onClick={() => setOpenDropdown((d) => (d === 'token' ? null : 'token'))}
>
{token && (
<span className={styles.tokenDot} style={{ background: token.color }}>
{token.logo ? <img src={token.logo} alt={token.ticker} /> : token.ticker[0]}
</span>
)}
<span className={styles.selectValue}>{token?.ticker ?? '—'}</span>
<svg
className={`${styles.chevron} ${openDropdown === 'token' ? styles.chevronOpen : ''}`}
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
>
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{openDropdown === 'token' && (
<div className={styles.dropdown}>
{tokens.map((t) => (
<button
key={t.ticker}
type="button"
className={`${styles.dropdownItem} ${t.ticker === ticker ? styles.dropdownItemActive : ''}`}
onClick={() => { setTicker(t.ticker); setOpenDropdown(null) }}
>
<span className={styles.tokenDot} style={{ background: t.color }}>
{t.logo ? <img src={t.logo} alt={t.ticker} /> : t.ticker[0]}
</span>
<span className={styles.dropdownTicker}>{t.ticker}</span>
<span className={styles.dropdownName}>{t.name}</span>
</button>
))}
</div>
)}
</div>
<div className={styles.selectGroup}>
<label className={styles.selectLabel}>Скорость</label>
<button
type="button"
className={`${styles.selectTrigger} ${openDropdown === 'speed' ? styles.selectTriggerOpen : ''}`}
onClick={() => setOpenDropdown((d) => (d === 'speed' ? null : 'speed'))}
>
<span className={`${styles.speedDot} ${styles[`speedDot_${speed}`]}`} />
<span className={styles.selectValue}>{speedLabel}</span>
<svg
className={`${styles.chevron} ${openDropdown === 'speed' ? styles.chevronOpen : ''}`}
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
>
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{openDropdown === 'speed' && (
<div className={styles.dropdown}>
{SPEEDS.map((s) => (
<button
key={s.value}
type="button"
className={`${styles.dropdownItem} ${s.value === speed ? styles.dropdownItemActive : ''}`}
onClick={() => { setSpeed(s.value); setOpenDropdown(null) }}
>
<span className={`${styles.speedDot} ${styles[`speedDot_${s.value}`]}`} />
<span>{s.label}</span>
</button>
))}
</div>
)}
</div>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Адрес кошелька</label>
<input
className={styles.input}
type="text"
placeholder="0x…"
value={address}
onChange={(e) => setAddress(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Количество</label>
<div className={styles.amountWrap}>
<input
className={`${styles.input} ${styles.amountInput}`}
type="number"
placeholder="0.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}
min="0"
step="any"
/>
{token && <span className={styles.amountTicker}>{token.ticker}</span>}
</div>
{token && (
<div className={styles.maxHint}>
Макс:{' '}
<button
type="button"
className={styles.maxBtn}
onClick={() => setAmount(token.bal)}
>
{token.bal} {token.ticker}
</button>
</div>
)}
</div>
<button className={styles.submitBtn} type="button">
Отправить
</button>
</div>
</div>
)
}

View File

@@ -1,10 +1,20 @@
import { useState } from 'react' import { useState } from 'react'
import { useTokenRows } from '../model/useTokenRows' import { useTokenRows } from '../model/useTokenRows'
import { SendModal } from '@widgets/send-modal'
import styles from './TokenTable.module.css' import styles from './TokenTable.module.css'
export function TokenTable() { export function TokenTable() {
const { rows, isLoading } = useTokenRows() const { rows, isLoading } = useTokenRows()
const [favs, setFavs] = useState<boolean[]>(() => rows.map((t) => t.fav)) const [favs, setFavs] = useState<boolean[]>(() => rows.map((t) => t.fav))
const [sendModal, setSendModal] = useState<{ open: boolean; ticker: string }>({ open: false, ticker: '' })
function openSend(ticker: string) {
setSendModal({ open: true, ticker })
}
function closeSend() {
setSendModal((s) => ({ ...s, open: false }))
}
function toggleFav(i: number) { function toggleFav(i: number) {
setFavs((prev) => prev.map((v, idx) => (idx === i ? !v : v))) setFavs((prev) => prev.map((v, idx) => (idx === i ? !v : v)))
@@ -69,7 +79,11 @@ export function TokenTable() {
</div> </div>
</td> </td>
<td className={styles.center}> <td className={styles.center}>
<button className={styles.sendBtn} type="button" onClick={(e) => e.stopPropagation()}> <button
className={styles.sendBtn}
type="button"
onClick={(e) => { e.stopPropagation(); openSend(t.ticker) }}
>
{sendIcon} {sendIcon}
Отправить Отправить
</button> </button>
@@ -120,11 +134,18 @@ export function TokenTable() {
</div> </div>
<div className={styles.mobileActions}> <div className={styles.mobileActions}>
<button className={styles.sendBtn} type="button"> <button className={styles.sendBtn} type="button" onClick={() => openSend(rows[0]?.ticker ?? '')}>
{sendIcon} {sendIcon}
Отправить Отправить
</button> </button>
</div> </div>
<SendModal
open={sendModal.open}
onClose={closeSend}
tokens={rows}
initialTicker={sendModal.ticker}
/>
</> </>
) )
} }

File diff suppressed because one or more lines are too long