first commit

This commit is contained in:
2026-05-09 00:38:56 +03:00
commit 51a44ef13d
156 changed files with 9832 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_URL=https://app.auth.elcsa.ru/v1
VITE_APP_NAME=Elcsa

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
.claude
.planning
.env

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ЭКСА — Ваш мост в мир цифровых активов</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3771
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "elcsa",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"@reduxjs/toolkit": "^2.5.1",
"@tanstack/react-query": "^5.100.9",
"axios": "^1.7.9",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.1.5",
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.5",
"vite-tsconfig-paths": "^5.1.4"
}
}

10
skills-lock.json Normal file
View File

@@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"claude-automation-recommender": {
"source": "smithery.ai",
"sourceType": "well-known",
"computedHash": "47e672bfcaa399d473cb33f2f298263d8554774314a71ef8761bd8d040351ceb"
}
}
}

175
src/Seed Phrase.html Normal file
View File

@@ -0,0 +1,175 @@
<!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>

9
src/app/App.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { RouterProvider, QueryProvider } from './providers'
export function App() {
return (
<QueryProvider>
<RouterProvider />
</QueryProvider>
)
}

View File

@@ -0,0 +1,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { ReactNode } from 'react'
const queryClient = new QueryClient()
export function QueryProvider({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}

View File

@@ -0,0 +1,27 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { HomePage } from '@pages/home'
import { WalletPage } from '@pages/wallet'
import { SwapPage } from '@pages/swap'
import { ProfilePage } from '@pages/profile'
import { LoginPage } from '@pages/login'
import { RegisterPage } from '@pages/register'
import { SeedPhrasePage } from '@pages/seed-phrase'
import { ROUTES } from '@shared/config/routes'
import { ScrollToTop } from './ScrollToTop'
export function RouterProvider() {
return (
<BrowserRouter>
<ScrollToTop />
<Routes>
<Route path={ROUTES.HOME} element={<HomePage />} />
<Route path={ROUTES.WALLET} element={<WalletPage />} />
<Route path={ROUTES.SWAP} element={<SwapPage />} />
<Route path={ROUTES.PROFILE} element={<ProfilePage />} />
<Route path={ROUTES.LOGIN} element={<LoginPage />} />
<Route path={ROUTES.REGISTER} element={<RegisterPage />} />
<Route path={ROUTES.SEED_PHRASE} element={<SeedPhrasePage />} />
</Routes>
</BrowserRouter>
)
}

View File

@@ -0,0 +1,12 @@
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
export function ScrollToTop() {
const { pathname } = useLocation()
useEffect(() => {
window.scrollTo(0, 0)
}, [pathname])
return null
}

View File

@@ -0,0 +1,2 @@
export { RouterProvider } from './RouterProvider'
export { QueryProvider } from './QueryProvider'

1
src/app/styles/fonts.css Normal file
View File

@@ -0,0 +1 @@
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap');

12
src/app/styles/global.css Normal file
View File

@@ -0,0 +1,12 @@
body {
font-family: var(--font-sans);
background: var(--bg-deep);
color: var(--text-primary);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
}

34
src/app/styles/reset.css Normal file
View File

@@ -0,0 +1,34 @@
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
button {
font: inherit;
color: inherit;
background: none;
border: none;
cursor: pointer;
}
a {
color: inherit;
text-decoration: none;
}
input {
font: inherit;
}
img,
svg {
display: block;
max-width: 100%;
}

View File

@@ -0,0 +1,20 @@
:root {
--bg-deep: #0a0b2e;
--bg-mid: #1b1547;
--grad-edge: #3d2a8e;
--grad-center: #5b3db8;
--text-primary: #ffffff;
--text-secondary: #b5b0cc;
--interactive: #4a6dff;
--highlight: #00d4ff;
--success: #26a17b;
--error: #ff4466;
--glass-border: rgba(255, 255, 255, 0.08);
--glass-bg: rgba(255, 255, 255, 0.04);
--font-sans: 'Manrope', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}

View File

@@ -0,0 +1,20 @@
import { api } from '@shared/api/base'
export interface RegistrationStartPayload {
email: string
}
export interface RegistrationCompletePayload {
email: string
password: string
confirm_password: string
code: string
}
export function registrationStart(payload: RegistrationStartPayload): Promise<string> {
return api.post<string>('/auth/registration/start', payload)
}
export function registrationComplete(payload: RegistrationCompletePayload): Promise<string> {
return api.post<string>('/auth/registration/complete', payload)
}

View File

@@ -0,0 +1,2 @@
export { registrationStart, registrationComplete } from './api/registrationApi'
export type { RegistrationStartPayload, RegistrationCompletePayload } from './api/registrationApi'

15
src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './app/styles/fonts.css'
import './app/styles/reset.css'
import './app/styles/variables.css'
import './app/styles/global.css'
import { App } from './app/App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

1
src/pages/home/index.ts Normal file
View File

@@ -0,0 +1 @@
export { HomePage } from './ui/HomePage'

View File

@@ -0,0 +1,21 @@
import { About } from '@widgets/about'
import { Converter } from '@widgets/currency-converter'
import { Footer } from '@widgets/footer'
import { Header } from '@widgets/header'
import { Hero } from '@widgets/hero'
import { NetworksTable } from '@widgets/networks-table'
export function HomePage() {
return (
<>
<Header />
<main>
<Hero />
<About />
<Converter />
<NetworksTable />
</main>
<Footer />
</>
)
}

1
src/pages/login/index.ts Normal file
View File

@@ -0,0 +1 @@
export { LoginPage } from './ui/LoginPage'

View File

@@ -0,0 +1,7 @@
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem 0;
}

View File

@@ -0,0 +1,10 @@
import { LoginForm } from '@widgets/login-form'
import styles from './LoginPage.module.css'
export function LoginPage() {
return (
<div className={styles.page}>
<LoginForm />
</div>
)
}

View File

@@ -0,0 +1 @@
export { ProfilePage } from './ui/ProfilePage'

View File

@@ -0,0 +1,156 @@
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-deep);
}
.main {
max-width: 1024px;
width: 100%;
margin: 0 auto;
padding: 40px 32px 60px;
display: flex;
gap: 32px;
}
/* Desktop: avatar is left column, sections grow right */
.profileTop {
flex-shrink: 0;
}
.userInfo {
display: none;
}
.sections {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
.grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.grid1 {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
.mnemonicRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
}
.mnemonicInfo {
display: flex;
align-items: center;
gap: 12px;
}
.mnemonicIcon {
font-size: 16px;
}
.mnemonicText {
color: var(--text-secondary);
font-size: 14px;
font-weight: 400;
}
/* < 1024px: column layout, avatar + userInfo side by side at top */
@media (max-width: 1023px) {
.main {
flex-direction: column;
padding: 24px 20px 40px;
gap: 24px;
}
.grid2 {
gap: 12px;
}
.profileTop {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 24px;
}
.userInfo {
display: flex;
flex-direction: column;
gap: 6px;
justify-content: center;
}
.userName {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.userBalance {
font-size: 32px;
font-weight: 800;
color: var(--text-primary);
}
.userBalanceRub {
font-size: 13px;
color: var(--text-secondary);
}
}
/* < 650px: card padding 16px (в ProfileSection.module.css),
кнопка мнемоники — 100% ширины */
@media (max-width: 649px) {
.mnemonicRow {
flex-direction: column;
align-items: stretch;
gap: 16px;
}
}
/* < 640px: инпуты в колонку */
@media (max-width: 639px) {
.main {
padding: 16px 16px 40px;
}
.grid2 {
grid-template-columns: 1fr;
}
}
/* < 550px: меньше текст и аватар */
@media (max-width: 549px) {
.profileTop {
gap: 16px;
}
.userInfo {
padding-top: 8px;
}
.userName {
font-size: 18px;
}
.userBalance {
font-size: 22px;
margin-top: 4px;
}
.userBalanceRub {
font-size: 12px;
}
}

View File

@@ -0,0 +1,64 @@
import { Button, FormField } from '@shared/ui'
import { WalletHeader } from '@widgets/wallet-header'
import { ProfileAvatar, ProfileSection } from '@widgets/profile'
import styles from './ProfilePage.module.css'
export function ProfilePage() {
return (
<div className={styles.page}>
<WalletHeader />
<main className={styles.main}>
<div className={styles.profileTop}>
<ProfileAvatar />
<div className={styles.userInfo}>
<span className={styles.userName}>Иванов Иван Иванович</span>
<span className={styles.userBalance}>$245.00</span>
<span className={styles.userBalanceRub}> 22 340,50 </span>
</div>
</div>
<div className={styles.sections}>
<ProfileSection title="Личные данные">
<div className={styles.grid2}>
<FormField label="Полное ФИО" value="Иванов Иван Иванович" placeholder="Например: Иванов Иван Иванович" />
<FormField label="Адрес электронной почты" value="ivanov@mail.ru" type="email" icon="check" placeholder="example@mail.ru" readOnly />
<FormField label="Серия и номер паспорта" value="4515 123456" placeholder="4515 123456" readOnly />
<FormField label="Номер телефона" value="+7 (999) 123-45-67" type="tel" icon="check" placeholder="+7 (999) 000-00-00" readOnly />
</div>
</ProfileSection>
<ProfileSection title="Верификация">
<div className={styles.grid2}>
<FormField label="ИНН" value="7712345678" readOnly icon="lock" placeholder="123456789012" />
<FormField label="ID аккаунта" value="ECSA-00184729" readOnly icon="lock" placeholder="ECSA-00000000" />
</div>
</ProfileSection>
<ProfileSection
title="Безопасность"
actions={
<>
<Button variant="danger"> Посмотреть приватный ключ</Button>
<Button variant="primary">СОХРАНИТЬ</Button>
</>
}
>
<div className={styles.grid1}>
<FormField label="Адрес ERC-20" readOnly icon="lock" value="0x1a2B3c4D5e6F7a8B9c0D1e2F3a4B5c6D7e8F9a0b" placeholder="0x0000000000000000000000000000000000000000" />
</div>
</ProfileSection>
<ProfileSection title="Мнемоника">
<div className={styles.mnemonicRow}>
<div className={styles.mnemonicInfo}>
<span className={styles.mnemonicIcon}>🔑</span>
<span className={styles.mnemonicText}>Сид-фраза из 12 слов для восстановления кошелька</span>
</div>
<Button variant="danger"> Показать мнемонику</Button>
</div>
</ProfileSection>
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1 @@
export { RegisterPage } from './ui/RegisterPage'

View File

@@ -0,0 +1,7 @@
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem 0;
}

View File

@@ -0,0 +1,10 @@
import { RegisterForm } from '@widgets/register-form'
import styles from './RegisterPage.module.css'
export function RegisterPage() {
return (
<div className={styles.page}>
<RegisterForm />
</div>
)
}

View File

@@ -0,0 +1 @@
export { SeedPhrasePage } from './ui/SeedPhrasePage'

View File

@@ -0,0 +1,33 @@
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-deep);
}
.main {
flex: 1;
padding: 40px 32px 60px;
max-width: 1200px;
width: 100%;
margin: 0 auto;
position: relative;
}
.glow {
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
width: 600px;
height: 320px;
background: radial-gradient(ellipse, rgba(61, 42, 142, 0.15), transparent 70%);
pointer-events: none;
z-index: 0;
}
@media (max-width: 900px) {
.main {
padding: 20px 16px 40px;
}
}

View File

@@ -0,0 +1,17 @@
import { WalletHeader } from '@widgets/wallet-header'
import { SeedPhraseWidget } from '@widgets/seed-phrase'
import styles from './SeedPhrasePage.module.css'
const MOCK_WORDS = ['egg', 'phone', 'long', 'vibe', 'potato', 'soup', 'skirt', 'black', 'phase', 'word', 'num', 'cucumber']
export function SeedPhrasePage() {
return (
<div className={styles.page}>
<WalletHeader />
<main className={styles.main}>
<div className={styles.glow} />
<SeedPhraseWidget words={MOCK_WORDS} />
</main>
</div>
)
}

1
src/pages/swap/index.ts Normal file
View File

@@ -0,0 +1 @@
export { SwapPage } from './ui/SwapPage'

View File

@@ -0,0 +1,52 @@
.page {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--bg-deep);
}
.tabs {
display: flex;
gap: 8px;
padding: 24px 28px 0;
}
.tab {
padding: 10px 24px;
border-radius: 10px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
border: none;
font-family: var(--font-sans);
letter-spacing: 0.5px;
transition: all 0.2s;
}
.active {
background: linear-gradient(135deg, var(--grad-edge), var(--grad-center));
color: var(--text-primary);
}
.inactive {
background: rgba(255, 255, 255, 0.06);
color: var(--text-secondary);
}
.inactive:hover {
color: var(--text-primary);
}
.main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 20px 48px;
}
@media (max-width: 650px) {
.main {
padding: 32px 20px;
}
}

View File

@@ -0,0 +1,38 @@
import { useState } from 'react'
import { Footer } from '@widgets/footer'
import { SwapForm } from '@widgets/swap-form'
import { WalletHeader } from '@widgets/wallet-header'
import styles from './SwapPage.module.css'
type Tab = 'swap' | 'bridge'
export function SwapPage() {
const [tab, setTab] = useState<Tab>('swap')
return (
<div className={styles.page}>
<WalletHeader />
<div className={styles.tabs}>
<button
className={`${styles.tab} ${tab === 'swap' ? styles.active : styles.inactive}`}
onClick={() => setTab('swap')}
>
СВОП
</button>
<button
className={`${styles.tab} ${tab === 'bridge' ? styles.active : styles.inactive}`}
onClick={() => setTab('bridge')}
>
БРИДЖ
</button>
</div>
<main className={styles.main}>
<SwapForm />
</main>
<Footer />
</div>
)
}

View File

@@ -0,0 +1 @@
export { WalletPage } from './ui/WalletPage'

View File

@@ -0,0 +1,40 @@
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-deep);
}
.main {
flex: 1;
padding: 28px 32px 40px;
max-width: 1200px;
width: 100%;
margin: 0 auto;
position: relative;
}
.glow {
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
width: 600px;
height: 320px;
background: radial-gradient(ellipse, rgba(61, 42, 142, 0.15), transparent 70%);
pointer-events: none;
z-index: 0;
}
@media (max-width: 992px) {
.glow {
width: auto;
height: auto;
}
}
@media (max-width: 900px) {
.main {
padding: 20px 16px 32px;
}
}

View File

@@ -0,0 +1,17 @@
import { BalanceCard } from '@widgets/balance-card'
import { TokenTable } from '@widgets/token-table'
import { WalletHeader } from '@widgets/wallet-header'
import styles from './WalletPage.module.css'
export function WalletPage() {
return (
<div className={styles.page}>
<WalletHeader />
<main className={styles.main}>
<div className={styles.glow} />
<BalanceCard />
<TokenTable />
</main>
</div>
)
}

27
src/shared/api/base.ts Normal file
View File

@@ -0,0 +1,27 @@
import { API_URL } from '@shared/config/env'
import { getCsrfToken } from './csrf'
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = await getCsrfToken()
const res = await fetch(`${API_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token,
...options.headers,
},
})
const data = await res.json()
if (!res.ok) throw data
return data as T
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
}

16
src/shared/api/csrf.ts Normal file
View File

@@ -0,0 +1,16 @@
import { API_URL } from '@shared/config/env'
interface CsrfResponse {
header_name: string
token: string
}
let cachedToken: string | null = null
export async function getCsrfToken(): Promise<string> {
if (cachedToken) return cachedToken
const res = await fetch(`${API_URL}/csrf/token`)
const data: CsrfResponse = await res.json()
cachedToken = data.token
return cachedToken
}

11
src/shared/api/types.ts Normal file
View File

@@ -0,0 +1,11 @@
export interface ValidationError {
loc: (string | number)[]
msg: string
type: string
input: string
ctx: Record<string, unknown>
}
export interface ApiErrorResponse {
detail: ValidationError[]
}

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="151" height="172" viewBox="0 0 151 172" fill="none">
<path d="M144.518 39.2113L82.214 3.03438C80.1932 1.87961 77.9919 1.28418 75.7545 1.28418C73.5352 1.28418 71.3158 1.87961 69.3311 3.03438L7.00937 39.2474C3.05788 41.5569 0.585938 45.8151 0.585938 50.4162V122.842C0.585938 127.443 3.05788 131.756 7.00937 134.047L18.2143 140.561L34.5616 150.052L38.2243 139.965L71.8932 47.1143C72.2 46.2121 71.5504 45.2378 70.54 45.2378H54.752C53.5792 45.2378 52.4966 45.9775 52.1177 47.0782L21.8951 130.474L12.2961 124.899C11.5743 124.484 11.1232 123.708 11.1232 122.878V50.4162C11.1232 49.5862 11.5743 48.8103 12.2961 48.3953L74.6178 12.1824C74.9606 11.9658 75.3756 11.8756 75.7725 11.8756C76.1875 11.8756 76.5665 11.9839 76.9454 12.1824L139.285 48.3953C140.007 48.8103 140.458 49.5862 140.458 50.4162V122.842C140.458 123.672 140.007 124.448 139.285 124.863L129.794 130.384L104.822 61.4768C104.371 60.2137 102.639 60.2137 102.188 61.4768L93.9602 84.1392C93.7257 84.7707 93.7257 85.4744 93.9602 86.0879L113.447 139.875L106.969 143.646L91.0192 99.6023C90.5681 98.3393 88.8359 98.3393 88.3848 99.6023L80.1571 122.265C79.9225 122.896 79.9225 123.6 80.1571 124.213L90.6403 153.137L76.9634 161.094C76.6206 161.311 76.2056 161.401 75.7906 161.401C75.3756 161.401 74.9967 161.293 74.6178 161.094L61.1033 153.227L99.5537 47.1323C99.8965 46.1941 99.2108 45.2017 98.2365 45.2017H82.4486C81.2758 45.2017 80.1932 45.9414 79.8142 47.0421L44.756 143.718L41.0932 153.805L57.4405 163.295L69.3672 170.224C71.3519 171.379 73.5713 171.974 75.7906 171.974C78.0099 171.974 80.2292 171.379 82.214 170.224L144.554 134.011C148.541 131.701 150.977 127.443 150.977 122.806V50.4162C150.977 45.8151 148.505 41.5028 144.554 39.2113H144.518Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 6H15C16.6569 6 18 7.34315 18 9C18 10.6569 16.6569 12 15 12M10 6V12M10 6H7M10 6V3M15 12H10M15 12C16.6569 12 18 13.3431 18 15C18 16.6569 16.6569 18 15 18H10M10 12V18M10 18H7M10 18V21M13 6V3M13 21V18" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 518 B

35
src/shared/assets/eth.svg Normal file
View File

@@ -0,0 +1,35 @@
<?xml version="1.0" ?>
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 32 32" id="_x3C_Layer_x3E_" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<style type="text/css">
<![CDATA[
.st0{fill:none;stroke:#fff;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
.st1{fill:none;stroke:#fff;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
]]>
</style>
<g id="Ethereum_x2C__crypto_x2C__cryptocurrency_1_">
<g id="XMLID_15_">
<polygon class="st1" id="XMLID_8_" points="16.01,1.5 7.62,16.23 16.01,21.5 24.38,16.18 "/>
<line class="st1" id="XMLID_31_" x1="16.01" x2="16.01" y1="30.5" y2="24.1"/>
<polygon class="st1" id="XMLID_12_" points="16.01,30.5 7.62,18.83 16.01,24.1 24.38,18.78 "/>
<polygon class="st1" id="XMLID_30_" points="16.01,12.3 7.62,16.23 16.01,21.5 24.38,16.18 "/>
<line class="st1" id="XMLID_32_" x1="16.01" x2="16.01" y1="1.5" y2="21.5"/>
<polygon class="st1" id="XMLID_192_" points="16.01,1.5 7.62,16.23 16.01,21.5 24.38,16.18 "/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,3 @@
<svg width="48" height="48" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.827 1.094a3.733 3.733 0 00-3.733 3.733v6.533a3.733 3.733 0 003.733 3.734h6.533a3.733 3.733 0 003.734-3.734V4.827a3.733 3.733 0 00-3.734-3.733H4.827zm5.454 7a2.187 2.187 0 11-4.375 0 2.187 2.187 0 014.375 0zm1.313 0a3.5 3.5 0 11-7 0 3.5 3.5 0 017 0zm.469-2.938a1.25 1.25 0 100-2.5 1.25 1.25 0 000 2.5z" fill="#000"></path>
</svg>

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.08398 5.22265C7.17671 5.08355 7.33282 5 7.5 5H18.5C18.6844 5 18.8538 5.10149 18.9408 5.26407C19.0278 5.42665 19.0183 5.62392 18.916 5.77735L16.916 8.77735C16.8233 8.91645 16.6672 9 16.5 9H5.5C5.3156 9 5.14617 8.89851 5.05916 8.73593C4.97215 8.57335 4.98169 8.37608 5.08398 8.22265L7.08398 5.22265ZM7.76759 6L6.43426 8H16.2324L17.5657 6H7.76759Z" fill="#fff"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.08398 15.2226C7.17671 15.0836 7.33282 15 7.5 15H18.5C18.6844 15 18.8538 15.1015 18.9408 15.2641C19.0278 15.4267 19.0183 15.6239 18.916 15.7774L16.916 18.7774C16.8233 18.9164 16.6672 19 16.5 19H5.5C5.3156 19 5.14617 18.8985 5.05916 18.7359C4.97215 18.5734 4.98169 18.3761 5.08398 18.2226L7.08398 15.2226ZM7.76759 16L6.43426 18H16.2324L17.5657 16H7.76759Z" fill="#fff"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.08398 13.7774C7.17671 13.9164 7.33282 14 7.5 14H18.5C18.6844 14 18.8538 13.8985 18.9408 13.7359C19.0278 13.5733 19.0183 13.3761 18.916 13.2226L16.916 10.2226C16.8233 10.0836 16.6672 10 16.5 10H5.5C5.3156 10 5.14617 10.1015 5.05916 10.2641C4.97215 10.4267 4.98169 10.6239 5.08398 10.7774L7.08398 13.7774ZM7.76759 13L6.43426 11H16.2324L17.5657 13H7.76759Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="735.823910pt" height="388.545063pt" viewBox="0 0 735.823910 388.545063"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.16, written by Peter Selinger 2001-2019
</metadata>
<rect width="100%" height="100%" fill="none"/><g transform="translate(-7.217511,405.779758) scale(0.100000,-0.100000)"
fill="#ffffff" stroke="none">
<path d="M1798 4049 c-430 -42 -842 -238 -1148 -548 -301 -304 -486 -664 -555
-1081 -25 -154 -31 -424 -11 -560 78 -530 310 -943 721 -1283 108 -89 335
-218 495 -280 396 -155 886 -166 1300 -28 135 45 358 157 475 239 266 185 495
448 625 720 24 50 47 92 50 92 3 0 34 -57 70 -127 192 -377 514 -681 905 -855
474 -212 1031 -221 1510 -26 569 233 990 720 1136 1313 126 515 51 1029 -217
1487 -254 433 -705 771 -1187 887 -194 47 -261 55 -447 55 -245 1 -433 -30
-660 -109 -263 -91 -537 -269 -741 -481 -117 -122 -269 -344 -338 -492 -14
-29 -27 -52 -31 -52 -3 0 -21 30 -39 68 -103 210 -277 437 -456 595 -395 347
-927 517 -1457 466z m432 -83 c648 -87 1206 -513 1457 -1113 58 -139 70 -143
109 -40 86 229 283 517 465 681 172 156 423 309 613 375 241 85 441 116 686
108 197 -7 344 -32 523 -91 549 -180 993 -630 1176 -1194 72 -221 86 -314 86
-587 -1 -268 -10 -343 -70 -540 -168 -552 -569 -985 -1113 -1199 -194 -76
-520 -130 -722 -120 -606 32 -1137 334 -1468 835 -54 82 -134 237 -172 334
-46 115 -55 113 -110 -22 -174 -433 -499 -773 -932 -978 -184 -87 -403 -143
-642 -165 -291 -26 -662 47 -937 185 -449 225 -784 611 -938 1082 -71 216 -86
314 -86 583 0 183 4 256 18 330 91 489 342 897 733 1191 256 193 549 309 889
352 76 10 340 5 435 -7z"/>
<path d="M5120 2656 c0 -459 -2 -515 -16 -520 -9 -3 -78 -6 -155 -6 l-139 0 0
-55 0 -55 155 0 156 0 -3 -127 -3 -128 -150 -5 -150 -5 -3 -47 -3 -48 155 0
156 0 2 -327 3 -328 55 0 55 0 3 327 2 328 363 2 362 3 0 45 0 45 -360 5 -360
5 -3 127 -3 126 383 4 383 4 82 27 c246 82 396 288 395 542 0 234 -134 436
-345 522 -109 45 -207 53 -629 53 l-388 0 0 -514z m940 375 c171 -60 266 -168
306 -351 52 -230 -99 -468 -337 -531 -47 -13 -722 -26 -765 -15 l-24 6 0 461
0 461 378 -4 c362 -4 380 -5 442 -27z"/>
<path d="M1199 3044 c-5 -7 -8 -75 -7 -155 l3 -144 300 -5 300 -5 3 -317 2
-318 190 0 c174 0 190 1 191 18 0 9 1 152 2 317 l2 300 303 3 302 2 0 155 c0
117 -3 155 -12 156 -7 0 -363 1 -791 2 -571 1 -781 -1 -788 -9z"/>
<path d="M1555 2404 c-279 -30 -473 -77 -561 -137 -209 -141 95 -294 668 -336
64 -5 121 -12 127 -15 8 -5 11 -136 11 -437 l0 -429 193 2 192 3 3 433 c2 403
3 432 20 432 9 0 82 7 162 15 357 35 609 109 668 195 77 115 -127 217 -528
264 -240 28 -269 29 -276 11 -4 -9 -3 -20 2 -25 5 -5 70 -14 144 -20 276 -23
501 -75 562 -129 43 -39 33 -63 -45 -100 -321 -157 -1451 -161 -1799 -8 -68
30 -94 62 -75 92 40 62 261 118 577 145 74 7 139 15 143 19 4 4 5 15 1 24 -7
20 -13 20 -189 1z"/>
<path d="M3365 2095 c-30 -30 -31 -54 -4 -89 40 -50 119 -21 119 44 0 64 -70
91 -115 45z"/>
<path d="M3726 2099 c-52 -41 -24 -119 44 -119 31 0 44 8 59 37 17 33 14 51
-14 78 -30 30 -54 31 -89 4z"/>
<path d="M4100 2113 c-42 -17 -53 -80 -19 -114 28 -28 76 -22 102 13 18 24 19
30 7 58 -16 39 -53 57 -90 43z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,3 @@
<svg width="48" height="48" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="M1.108 7.617C5.4 5.768 8.262 4.55 9.694 3.961c4.088-1.682 4.938-1.974 5.492-1.984.68-.011.86.551.804 1.137-.221 2.302-1.18 7.887-1.668 10.465-.43 2.277-1.958 1.292-3.338.397-1.296-.84-2.028-1.362-3.286-2.182-1.453-.947-.511-1.467.317-2.318.217-.223 3.984-3.61 4.057-3.918.06-.256-.167-.333-.373-.286-.13.029-2.2 1.382-6.21 4.058-.588.399-1.12.593-1.597.583-.66-.014-2.26-.583-3.88-1.492-.097-.277.399-.53 1.096-.804z" fill="#000"></path>
</svg>

After

Width:  |  Height:  |  Size: 541 B

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="309.147797pt" height="144.859221pt" viewBox="0 0 309.147797 144.859221"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.16, written by Peter Selinger 2001-2019
</metadata>
<rect width="100%" height="100%" fill="none"/><g transform="translate(-8.181291,146.963747) scale(0.100000,-0.100000)"
fill="#ffffff" stroke="none">
<path d="M685 1459 c-328 -55 -585 -344 -602 -679 -14 -267 104 -496 334 -643
282 -183 645 -142 888 98 98 96 128 143 325 490 84 149 172 300 195 336 180
279 488 420 765 350 253 -64 426 -222 507 -466 24 -70 27 -96 27 -205 0 -109
-3 -135 -27 -207 -49 -148 -167 -293 -306 -377 -261 -159 -617 -110 -830 114
-30 32 -73 89 -96 126 -43 70 -75 94 -75 55 0 -29 113 -194 163 -236 141 -122
290 -185 453 -193 126 -6 219 10 318 53 206 89 356 258 426 480 30 95 31 257
2 367 -37 138 -111 259 -219 357 -81 75 -234 154 -338 176 -249 52 -515 -37
-697 -234 -76 -82 -145 -187 -283 -436 -161 -288 -191 -340 -242 -412 -81
-115 -208 -214 -342 -266 -64 -25 -94 -31 -189 -35 -146 -6 -213 8 -341 70
-88 42 -117 62 -181 127 -166 169 -236 408 -181 623 63 245 223 419 461 499
211 71 430 28 628 -124 55 -41 134 -138 184 -222 26 -43 48 -52 48 -20 0 19
-53 108 -101 170 -147 193 -433 305 -674 264z"/>
<path d="M2339 1018 c-51 -95 -108 -198 -126 -228 -18 -30 -33 -61 -33 -69 0
-13 53 -102 191 -323 40 -65 59 -87 73 -85 26 3 198 279 183 293 -8 9 -85 -89
-132 -168 -26 -44 -49 -77 -51 -75 -17 16 -174 280 -170 285 3 3 42 -16 87
-42 77 -45 82 -47 105 -31 30 20 32 39 2 31 -17 -4 -51 12 -125 57 -57 34
-103 67 -103 74 0 19 194 374 204 375 9 0 48 -66 158 -272 48 -90 54 -98 70
-82 9 9 -1 37 -46 124 -96 187 -166 308 -180 308 -7 0 -55 -78 -107 -172z"/>
<path d="M688 1083 l-3 -38 -57 -3 c-50 -3 -58 -6 -58 -22 0 -16 7 -20 30 -20
l30 0 -2 -247 -3 -248 -27 -3 c-30 -4 -38 -26 -12 -36 9 -3 35 -6 59 -6 43 0
44 0 47 -37 2 -27 8 -39 21 -41 14 -3 17 4 17 37 0 37 3 41 24 41 21 0 25 -5
28 -37 4 -52 32 -52 36 -2 3 36 5 37 50 44 59 9 93 26 125 65 54 63 48 152
-15 214 l-40 40 21 37 c12 20 21 49 21 63 0 65 -58 137 -120 151 -23 5 -30 12
-30 30 0 34 -10 55 -26 55 -8 0 -14 -10 -14 -24 0 -39 -11 -56 -35 -56 -20 0
-24 6 -27 37 -2 26 -8 39 -20 41 -13 2 -17 -5 -20 -35z m176 -97 c42 -18 66
-51 66 -94 0 -76 -42 -102 -165 -102 l-85 0 0 105 0 105 75 0 c42 0 90 -6 109
-14z m67 -253 c41 -24 62 -83 49 -133 -19 -72 -87 -103 -218 -98 l-77 3 -3
109 c-1 61 0 116 2 123 8 21 210 18 247 -4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.6109 4.68601C4.72546 4.54406 4.90822 4.47575 5.0878 4.50778L16.575 6.55656C16.6715 6.57377 16.7608 6.61896 16.8318 6.68651L19.3446 9.07676C19.5379 9.26064 19.5528 9.5639 19.3784 9.76583L11.122 19.3268C11.0084 19.4583 10.8347 19.5214 10.6632 19.4935C10.4917 19.4656 10.347 19.3506 10.281 19.1898L4.53742 5.18979C4.46819 5.02103 4.49635 4.82796 4.6109 4.68601ZM6.19646 6.59904L10.4853 17.053L11.2894 10.6786L6.19646 6.59904ZM12.2688 10.9045L11.4468 17.4207L17.7491 10.1226L12.2688 10.9045ZM17.9075 9.08986L13.7298 9.68594L16.4453 7.69901L17.9075 9.08986ZM15.2483 7.33573L6.84451 5.83688L11.8343 9.83381L15.2483 7.33573Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 912 B

View File

@@ -0,0 +1,3 @@
<svg width="48" height="48" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.733 11.784c-.708-1.191-1.221-2.408-1.221-3.823 0-3.566 2.905-6.457 6.488-6.457 3.583 0 6.488 2.891 6.488 6.457 0 3.565-2.905 6.456-6.488 6.456-1.332 0-2.496-.452-3.64-1.079-.734.18-1.468.362-2.2.55.195-.7.387-1.402.573-2.104zM0 16s2.944-.768 4.147-1.06A7.995 7.995 0 008 15.92c4.418 0 8-3.564 8-7.96S12.418 0 8 0 0 3.564 0 7.96c0 1.479.405 2.862 1.11 4.048C.929 12.693 0 16 0 16zm8.118-6.637a501.25 501.25 0 00-1.265-1.26c-.545-.576-.646-1.11-.392-1.298l.52-.298a.675.675 0 00.248-.924l-.78-1.344a.681.681 0 00-.928-.248L5 4.29c-1.685.968.271 4.078 1.09 4.892l.944.94c.818.814 3.943 2.76 4.916 1.084l.3-.517a.674.674 0 00-.249-.924l-1.35-.776a.682.682 0 00-.929.247l-.3.517c-.188.252-.725.152-1.305-.39z" fill="#000"></path>
</svg>

After

Width:  |  Height:  |  Size: 871 B

View File

@@ -0,0 +1,3 @@
export const COUNTDOWN_DAYS = 3
export const USDT_RATE = 79.83
export const GAS_PRICE = 63.68

1
src/shared/config/env.ts Normal file
View File

@@ -0,0 +1 @@
export const API_URL = import.meta.env.VITE_API_URL as string

View File

@@ -0,0 +1,9 @@
export const ROUTES = {
HOME: '/',
WALLET: '/wallet',
SWAP: "/swap",
LOGIN: '/login',
REGISTER: '/register',
PROFILE: '/profile',
SEED_PHRASE: '/seed-phrase',
} as const

View File

View File

View File

View File

View File

@@ -0,0 +1,56 @@
.btn {
height: 44px;
padding: 0 20px;
border-radius: 10px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
font-family: var(--font-sans);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background 0.2s, opacity 0.2s, border-color 0.2s;
}
.primary {
background: linear-gradient(135deg, var(--grad-edge), var(--grad-center));
border: none;
color: var(--text-primary);
}
.primary:hover {
opacity: 0.85;
}
.danger {
background: rgba(255, 77, 77, 0.1);
border: 1px solid rgba(255, 77, 77, 0.3);
color: #ff4d4d;
}
.danger:hover {
background: rgba(255, 77, 77, 0.2);
}
.ghost {
background: rgba(74, 109, 255, 0.15);
border: 1px solid rgba(74, 109, 255, 0.3);
color: var(--interactive);
}
.ghost:hover {
background: rgba(74, 109, 255, 0.25);
}
.outline {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.15);
color: var(--text-primary);
}
.outline:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.25);
}

View File

@@ -0,0 +1,17 @@
import styles from './Button.module.css'
interface Props {
variant: 'primary' | 'danger' | 'ghost' | 'outline'
children: React.ReactNode
onClick?: () => void
type?: 'button' | 'submit'
disabled?: boolean
}
export function Button({ variant, children, onClick, type = 'button', disabled }: Props) {
return (
<button type={type} className={`${styles.btn} ${styles[variant]}`} onClick={onClick} disabled={disabled}>
{children}
</button>
)
}

View File

@@ -0,0 +1 @@
export { Button } from './Button'

View File

@@ -0,0 +1,97 @@
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: 0.08em;
}
.wrap {
position: relative;
}
.input {
width: 100%;
height: 48px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: var(--text-primary);
font-size: 14px;
font-family: var(--font-sans);
padding: 0 16px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.input:focus {
border-color: var(--interactive);
box-shadow: 0 0 0 3px rgba(74, 109, 255, 0.15);
}
.readonly {
background: rgba(255, 255, 255, 0.03);
border-style: dashed;
color: var(--text-secondary);
cursor: pointer;
}
.copied {
border-color: var(--success);
border-style: solid;
box-shadow: 0 0 0 3px rgba(38, 161, 123, 0.15);
}
.iconCopied {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--success);
font-size: 16px;
}
.iconCheck {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--success);
font-size: 16px;
}
.iconLock {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 14px;
}
.withToggle {
padding-right: 48px;
}
.togglePw {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
display: flex;
align-items: center;
transition: color 0.2s;
padding: 0;
}
.togglePw:hover {
color: var(--text-primary);
}

View File

@@ -0,0 +1,68 @@
import { useState } from 'react'
import styles from './FormField.module.css'
interface Props {
label: string
value: string
placeholder?: string
type?: 'text' | 'email' | 'tel' | 'password'
onChange?: (value: string) => void
readOnly?: boolean
required?: boolean
icon?: 'check' | 'lock'
}
export function FormField({ label, value, placeholder, type = 'text', onChange, readOnly, required, icon }: Props) {
const [copied, setCopied] = useState(false)
const [isVisible, setIsVisible] = useState(false)
const isPassword = type === 'password'
const inputType = isPassword ? (isVisible ? 'text' : 'password') : type
const handleClick = () => {
if (!readOnly) return
navigator.clipboard.writeText(value).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}
return (
<div className={styles.field}>
<label className={styles.label}>{label}</label>
<div className={styles.wrap} onClick={handleClick}>
<input
className={`${styles.input} ${isPassword ? styles.withToggle : ''} ${readOnly ? styles.readonly : ''} ${copied ? styles.copied : ''}`}
type={inputType}
{...(onChange
? { value, onChange: (e) => onChange(e.target.value) }
: { defaultValue: value }
)}
placeholder={placeholder}
readOnly={readOnly}
required={required}
/>
{isPassword && (
<button
type="button"
className={styles.togglePw}
onClick={(e) => { e.stopPropagation(); setIsVisible((v) => !v) }}
aria-label={isVisible ? 'Скрыть пароль' : 'Показать пароль'}
>
{isVisible ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94" /><path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19" /><line x1="1" y1="1" x2="23" y2="23" />
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z" /><circle cx="12" cy="12" r="3" />
</svg>
)}
</button>
)}
{icon === 'check' && <span className={styles.iconCheck}></span>}
{icon === 'lock' && <span className={styles.iconLock}>🔒</span>}
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { FormField } from './FormField'

View File

@@ -0,0 +1,11 @@
.pill {
display: inline-block;
background: rgba(74, 109, 255, 0.12);
border: 1px solid rgba(74, 109, 255, 0.3);
color: var(--interactive);
border-radius: 999px;
font-size: 14px;
font-variant: all-small-caps;
letter-spacing: 2px;
padding: 4px 14px;
}

View File

@@ -0,0 +1,10 @@
import type { ReactNode } from 'react'
import styles from './Pill.module.css'
interface Props {
children: ReactNode
}
export function Pill({ children }: Props) {
return <span className={styles.pill}>{children}</span>
}

View File

@@ -0,0 +1 @@
export { Pill } from './Pill'

View File

@@ -0,0 +1,19 @@
.btn {
width: 100%;
height: 56px;
background: linear-gradient(135deg, var(--grad-edge), var(--grad-center));
border: none;
border-radius: 14px;
color: var(--text-primary);
font-size: 17px;
font-weight: 700;
cursor: pointer;
font-family: var(--font-sans);
letter-spacing: 0.3px;
transition: filter 0.25s, box-shadow 0.25s;
}
.btn:hover {
filter: brightness(1.15);
box-shadow: 0 0 24px rgba(91, 61, 184, 0.5);
}

View File

@@ -0,0 +1,16 @@
import styles from './PrimaryButton.module.css'
interface Props {
label?: string
onClick?: () => void
type?: 'button' | 'submit'
disabled?: boolean
}
export function PrimaryButton({ label = 'Подтвердить своп', onClick, type = 'submit', disabled }: Props) {
return (
<button type={type} className={styles.btn} onClick={onClick} disabled={disabled}>
{label}
</button>
)
}

View File

@@ -0,0 +1 @@
export { PrimaryButton } from './PrimaryButton'

View File

@@ -0,0 +1,6 @@
.title {
color: var(--text-primary);
font-weight: 700;
font-size: 48px;
margin-top: 16px;
}

View File

@@ -0,0 +1,11 @@
import type { ReactNode } from 'react'
import styles from './Title.module.css'
interface Props {
children: ReactNode
}
export function Title({ children }: Props) {
return <h2 className={styles.title}>{children}</h2>
}

View File

@@ -0,0 +1,9 @@
.icon {
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
color: #fff;
flex-shrink: 0;
}

View File

@@ -0,0 +1,22 @@
import styles from './TokenIcon.module.css'
interface Props {
letter: string
color: string
logo?: string
size?: number
}
export function TokenIcon({ letter, color, logo, size = 40 }: Props) {
return (
<div
className={styles.icon}
style={{ background: logo ? 'transparent' : color, width: size, height: size, fontSize: size * 0.45 }}
>
{logo
? <img src={logo} alt={letter} style={{ width: size * 0.7, height: size * 0.7 }} />
: letter
}
</div>
)
}

View File

@@ -0,0 +1 @@
export { TokenIcon } from './TokenIcon'

5
src/shared/ui/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export { Button } from './Button'
export { FormField } from './FormField'
export { Pill } from './Pill'
export { PrimaryButton } from './PrimaryButton'
export { TokenIcon } from './TokenIcon'

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1 @@
export { About } from './ui/About'

View File

@@ -0,0 +1,123 @@
.section {
width: 100%;
background: var(--bg-deep);
padding: 80px 48px;
}
.wrap {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: 40% 60%;
gap: 48px;
align-items: center;
}
.descBlock {
border-left: 3px solid var(--interactive);
padding-left: 20px;
margin-top: 24px;
}
.descText {
color: var(--text-secondary);
line-height: 1.7;
font-size: 16px;
margin-bottom: 16px;
}
.descText:last-child {
margin-bottom: 0;
}
.right {
position: relative;
}
.glow {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(74, 109, 255, 0.08), transparent 70%);
filter: blur(80px);
pointer-events: none;
}
.row {
height: 80px;
display: flex;
align-items: center;
gap: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 0 12px;
transition: all 0.2s ease;
cursor: default;
border-left: 2px solid transparent;
position: relative;
z-index: 1;
}
.row[data-last] {
border-bottom: none;
}
.row[data-hovered] {
background: rgba(255, 255, 255, 0.02);
border-left-color: rgba(0, 196, 140, 0.4);
}
@media (max-width: 550px) {
.wrap {
gap: 2rem;
}
.glow {
height: auto;
}
.row {
height: auto;
margin-bottom: 1rem;
}
}
.check {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(0, 196, 140, 0.12);
border: 1px solid rgba(0, 196, 140, 0.2);
display: flex;
align-items: center;
justify-content: center;
color: #00c48c;
font-size: 16px;
flex-shrink: 0;
}
.text {
color: var(--text-primary);
font-size: 15px;
font-weight: 500;
line-height: 1.5;
}
@media (max-width: 1024px) {
.wrap {
grid-template-columns: 1fr;
}
.section {
padding: 40px 32px;
}
}
@media (max-width: 640px) {
.section {
padding-left: 24px;
padding-right: 24px;
}
}

View File

@@ -0,0 +1,51 @@
import { useState } from 'react'
import { Pill } from '@shared/ui'
import styles from './About.module.css'
import { Title } from '@shared/ui/Title/Title'
const THESES = [
'Вся деятельность компании соответствует законодательству Российской Федерации и требованиям регуляторов',
'Вся документация компании открыта и доступна для ознакомления',
'Операции защищены шифрованием уровня ERC-20 и проходят верификацию в блокчейне',
]
export function About() {
const [hovered, setHovered] = useState(-1)
return (
<section id="about" className={styles.section}>
<div className={styles.wrap}>
<div>
<Pill>О КОМПАНИИ</Pill>
<Title>О нас</Title>
<div className={styles.descBlock}>
<p className={styles.descText}>
ЭКСА молодая финтех-компания в сфере цифровых активов. Наша миссия сделать
оборот цифровых активов простым, прозрачным и законным.
</p>
<p className={styles.descText}>
Мы создаём инфраструктуру для операций с криптовалютой и комплексные решения для
физических и юридических лиц.
</p>
</div>
</div>
<div className={styles.right}>
<div className={styles.glow} />
{THESES.map((text, i) => (
<div
key={i}
className={styles.row}
data-hovered={hovered === i || undefined}
data-last={i === THESES.length - 1 || undefined}
onMouseEnter={() => setHovered(i)}
onMouseLeave={() => setHovered(-1)}
>
<div className={styles.check}></div>
<span className={styles.text}>{text}</span>
</div>
))}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1 @@
export { BalanceCard } from './ui/BalanceCard'

View File

@@ -0,0 +1,102 @@
.card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border);
border-radius: 20px;
padding: 32px 36px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28px;
}
@media (max-width: 768px) {
.card {
padding: 1rem 1.25rem;
}
}
.label {
font-size: 13px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
margin-bottom: 6px;
}
.amount {
font-size: 48px;
font-weight: 800;
line-height: 1.1;
font-family: var(--font-mono);
}
.rub {
font-size: 18px;
color: var(--text-secondary);
margin-top: 4px;
font-family: var(--font-mono);
}
.actions {
display: flex;
gap: 12px;
}
.btn {
display: flex;
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 14px;
padding: 14px 22px;
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
font-family: inherit;
}
.btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.25);
}
.btn img {
height: 28px;
}
@media (max-width: 900px) {
.card {
flex-direction: column;
align-items: flex-start;
gap: 20px;
}
.actions {
width: 100%;
justify-content: space-between;
}
.actions>* {
width: 100%;
}
}
@media (max-width: 550px) {
.amount {
font-size: 36px;
}
.actions {
flex-direction: column;
width: 100%;
}
.btn {
padding: 8px 1rem;
justify-content: center;
}
}

View File

@@ -0,0 +1,27 @@
import styles from './BalanceCard.module.css'
import topup from '@shared/assets/topup.svg'
import swap from '@shared/assets/swap.svg'
import { Link } from 'react-router-dom'
import { ROUTES } from '@shared/config/routes'
export function BalanceCard() {
return (
<div className={styles.card}>
<div className={styles.left}>
<div className={styles.label}>Общий баланс</div>
<div className={styles.amount}>$245.00</div>
<div className={styles.rub}> 22 340,50 </div>
</div>
<div className={styles.actions}>
<button className={styles.btn} type="button">
<img src={swap} alt="swap" />
Пополнить кошелёк
</button>
<Link to={ROUTES.SWAP} className={styles.btn} type="button">
<img src={topup} alt="topup" />
Своп / Бридж
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { Converter } from './ui/Converter'

View File

@@ -0,0 +1,27 @@
export interface Tier {
min: number
max: number
pct: number
}
export const TIERS: readonly Tier[] = [
{ min: 5_000, max: 30_000, pct: 8 },
{ min: 30_000, max: 100_000, pct: 6 },
{ min: 100_000, max: 600_000, pct: 4 },
] as const
export const TIER_MIN = TIERS[0].min
export const TIER_MAX = TIERS[TIERS.length - 1].max
export function findTier(amount: number): Pick<Tier, 'pct'> {
const match = TIERS.find((t) => amount >= t.min && amount <= t.max)
if (match) return match
if (amount > TIER_MAX) return { pct: TIERS[TIERS.length - 1].pct }
return { pct: TIERS[0].pct }
}
export function progressPercent(amount: number): number {
if (amount <= TIER_MIN) return 0
if (amount >= TIER_MAX) return 100
return ((amount - TIER_MIN) / (TIER_MAX - TIER_MIN)) * 100
}

View File

@@ -0,0 +1,51 @@
import { useMemo, useState } from 'react'
import { findTier, progressPercent } from './tiers'
export type ConverterMode = 'buy' | 'sell'
interface UseConverterArgs {
usdtRate: number
}
export function useConverter({ usdtRate }: UseConverterArgs) {
const [mode, setMode] = useState<ConverterMode>('buy')
const [rubVal, setRubVal] = useState<string>('10000')
const [agreed, setAgreed] = useState<boolean>(false)
const numRub = Number.parseFloat(rubVal) || 0
const result = useMemo(() => {
const tier = findTier(numRub)
const commission = (numRub * tier.pct) / 100
const sign = mode === 'buy' ? 1 : -1
const effectiveRate = usdtRate * (1 + (sign * tier.pct) / 100)
const usdtVal = numRub > 0 ? (numRub / effectiveRate).toFixed(2) : '0.00'
return {
tierPct: tier.pct,
commission,
effectiveRate,
usdtVal,
progress: progressPercent(numRub),
}
}, [numRub, mode, usdtRate])
function updateRub(raw: string) {
setRubVal(raw.replace(/[^0-9.]/g, ''))
}
function toggleMode() {
setMode((m) => (m === 'buy' ? 'sell' : 'buy'))
}
return {
mode,
setMode,
rubVal,
updateRub,
numRub,
agreed,
setAgreed,
toggleMode,
...result,
}
}

View File

@@ -0,0 +1,54 @@
.wrap {
display: flex;
align-items: flex-start;
gap: 12px;
cursor: pointer;
text-align: left;
background: none;
border: none;
padding: 0;
}
.box {
width: 20px;
height: 20px;
border-radius: 6px;
border: 2px solid var(--glass-border);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.3s;
margin-top: 2px;
}
.box svg {
opacity: 0;
transition: opacity 0.2s;
}
.box[data-checked] {
background: var(--grad-center);
border-color: var(--grad-center);
}
.box[data-checked] svg {
opacity: 1;
}
.text {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
max-width: 500px;
}
.link {
color: var(--interactive);
text-decoration: underline;
}
.required {
font-size: 11px;
opacity: 0.6;
}

View File

@@ -0,0 +1,42 @@
import styles from './AgreementCheckbox.module.css'
interface Props {
checked: boolean
onToggle: () => void
}
export function AgreementCheckbox({ checked, onToggle }: Props) {
return (
<button
type="button"
className={styles.wrap}
onClick={onToggle}
aria-pressed={checked}
>
<span className={styles.box} data-checked={checked || undefined}>
<svg width={12} height={12} viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path
d="M2 6l3 3 5-5"
stroke="#fff"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<span className={styles.text}>
Я ознакомлен и согласен с{' '}
<a
href="#"
className={styles.link}
onClick={(e) => e.preventDefault()}
>
публичной офертой
</a>
. Вся деятельность компании соответствует законодательству Российской Федерации.
<br />
<span className={styles.required}>ОБЯЗАТЕЛЬНОЕ ПОЛЕ</span>
</span>
</button>
)
}

View File

@@ -0,0 +1,82 @@
.title {
font-size: 16px;
font-weight: 600;
letter-spacing: 1px;
margin-bottom: 24px;
}
.table {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 24px;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
border: 1px solid var(--glass-border);
transition: all 0.3s ease;
}
.row[data-active] {
border-color: var(--grad-center);
background: rgba(91, 61, 184, 0.12);
}
.range {
font-size: 14px;
color: var(--text-secondary);
}
.pct {
font-family: var(--font-mono);
font-size: 16px;
font-weight: 700;
color: var(--highlight);
}
.progressBar {
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.06);
margin-bottom: 32px;
overflow: hidden;
}
.progressFill {
height: 100%;
border-radius: 3px;
background: linear-gradient(90deg, var(--grad-center), var(--highlight));
transition: width 0.5s ease;
}
.summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px;
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
border: 1px solid var(--glass-border);
margin-bottom: 16px;
}
.summary:last-child {
margin-bottom: 0;
}
.summaryLabel {
font-size: 13px;
color: var(--text-secondary);
}
.summaryValue {
font-family: var(--font-mono);
font-size: 16px;
font-weight: 600;
}

View File

@@ -0,0 +1,34 @@
import { TIERS } from '../model/tiers'
import styles from './CommissionTable.module.css'
import { Tiers } from './Tiers'
interface Props {
amount: number
progress: number
commission: number
effectiveRate: number
}
export function CommissionTable({ amount, progress, commission, effectiveRate }: Props) {
return (
<div>
<div className={styles.title}>КОМИССИЯ СЕРВИСА</div>
<div className={styles.table}>
<Tiers tiers={TIERS} amount={amount} />
</div>
<div className={styles.progressBar}>
<div className={styles.progressFill} style={{ width: `${progress}%` }} />
</div>
<div className={styles.summary}>
<span className={styles.summaryLabel}>Комиссия</span>
<span className={styles.summaryValue}>
{commission.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}
</span>
</div>
<div className={styles.summary}>
<span className={styles.summaryLabel}>Курс с комиссией</span>
<span className={styles.summaryValue}>{effectiveRate.toFixed(2)} </span>
</div>
</div>
)
}

View File

@@ -0,0 +1,240 @@
.section {
padding: 100px 48px;
background: var(--bg-deep);
}
.wrap {
max-width: 1200px;
margin: 0 auto;
background: var(--bg-mid);
border: 1px solid var(--glass-border);
border-radius: 24px;
padding: 32px;
position: relative;
overflow: hidden;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 24px;
margin-bottom: 40px;
}
.title {
font-size: clamp(36px, 4vw, 52px);
font-weight: 700;
}
.subtitle {
font-size: 14px;
color: var(--text-secondary);
margin-top: 8px;
letter-spacing: 1px;
}
.pills {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.pill {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--text-secondary);
}
.pillValue {
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--glass-border);
border-radius: 8px;
padding: 6px 14px;
font-family: var(--font-mono);
font-size: 14px;
color: var(--text-primary);
}
.body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 48px;
}
.tabs {
display: inline-flex;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--glass-border);
margin-bottom: 32px;
}
.tab {
padding: 12px 32px;
font-size: 14px;
font-weight: 600;
letter-spacing: 1px;
background: transparent;
color: var(--text-secondary);
transition: all 0.3s;
}
.tab[data-active] {
background: var(--grad-center);
color: var(--text-primary);
}
.tab:not([data-active]):hover {
background: rgba(255, 255, 255, 0.04);
}
.field {
margin-bottom: 24px;
}
.fieldLabel {
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 8px;
}
.fieldInput {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 0 16px;
transition: border-color 0.3s;
}
.fieldInput:focus-within {
border-color: var(--interactive);
}
.fieldInput input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 18px;
padding: 16px 0;
width: 100%;
}
.currency {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.currencyIcon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: #fff;
}
.currencyRub {
background: var(--interactive);
}
.currencyUsdt {
background: var(--success);
}
.swapWrap {
display: flex;
justify-content: center;
}
.swapBtn {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
color: var(--text-secondary);
}
.swapBtn:hover {
border-color: var(--highlight);
color: var(--highlight);
transform: rotate(180deg);
}
.bottom {
display: flex;
justify-content: center;
margin-top: 40px;
padding-top: 32px;
border-top: 1px solid var(--glass-border);
}
@media (max-width: 1024px) {
.body {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.section {
padding: 40px 32px;
}
.header {
margin-bottom: 1rem;
}
.tabs {
margin-bottom: 1.5rem;
display: flex;
}
.tab {
flex: 0 0 50%;
}
.field {
margin-bottom: 1rem;
}
.bottom {
margin-top: 1.5rem;
padding-top: 1rem;
}
.pills {
display: none;
}
}
@media (max-width: 640px) {
.section {
padding-left: 24px;
padding-right: 24px;
}
.wrap {
padding: 28px;
}
}

View File

@@ -0,0 +1,110 @@
import { GAS_PRICE, USDT_RATE } from '@shared/config/constants'
import { useConverter } from '../model/useConverter'
import { AgreementCheckbox } from './AgreementCheckbox'
import { CommissionTable } from './CommissionTable'
import styles from './Converter.module.css'
import { Title } from '@shared/ui/Title/Title'
export function Converter() {
const c = useConverter({ usdtRate: USDT_RATE })
return (
<section className={styles.section} id="converter">
<div className={styles.wrap}>
<div className={styles.header}>
<div>
<Title>Конвертация</Title>
<div className={styles.subtitle}>Данные обновляются в реальном времени</div>
</div>
<div className={styles.pills}>
<div className={styles.pill}>
Цена газа в RUB <span className={styles.pillValue}>{GAS_PRICE.toFixed(2)} RUB</span>
</div>
<div className={styles.pill}>
USDT/RUB <span className={styles.pillValue}>{USDT_RATE.toFixed(2)} </span>
</div>
</div>
</div>
<div className={styles.body}>
<div>
<div className={styles.tabs}>
<button
type="button"
className={styles.tab}
data-active={c.mode === 'buy' || undefined}
onClick={() => c.setMode('buy')}
>
КУПИТЬ
</button>
<button
type="button"
className={styles.tab}
data-active={c.mode === 'sell' || undefined}
onClick={() => c.setMode('sell')}
>
ПРОДАТЬ
</button>
</div>
<div className={styles.field}>
<div className={styles.fieldLabel}>Конвертируете</div>
<div className={styles.fieldInput}>
<input
type="text"
value={c.rubVal}
onChange={(e) => c.updateRub(e.target.value)}
placeholder="0"
inputMode="decimal"
/>
<div className={styles.currency}>
<span className={`${styles.currencyIcon} ${styles.currencyRub}`}></span> RUB
</div>
</div>
</div>
<div className={styles.swapWrap}>
<button
type="button"
className={styles.swapBtn}
onClick={c.toggleMode}
aria-label="Поменять направление"
>
<svg width={16} height={16} viewBox="0 0 16 16" fill="none">
<path
d="M8 2v12M4 10l4 4 4-4"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
<div className={styles.field}>
<div className={styles.fieldLabel}>Получаете</div>
<div className={styles.fieldInput}>
<input type="text" value={c.usdtVal} readOnly />
<div className={styles.currency}>
<span className={`${styles.currencyIcon} ${styles.currencyUsdt}`}></span> USDT
</div>
</div>
</div>
</div>
<CommissionTable
amount={c.numRub}
progress={c.progress}
commission={c.commission}
effectiveRate={c.effectiveRate}
/>
</div>
<div className={styles.bottom}>
<AgreementCheckbox checked={c.agreed} onToggle={() => c.setAgreed(!c.agreed)} />
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,27 @@
import { TIERS } from "../model/tiers"
import styles from './CommissionTable.module.css'
const ru = (n: number) => n.toLocaleString('ru-RU')
type TiersProps = {
tiers: typeof TIERS
amount: number
}
export const Tiers = ({ tiers, amount }: TiersProps) => {
return (
<>
{tiers.map((tier, i) => {
const active = amount >= tier.min && amount <= tier.max
return (
<div key={i} className={styles.row} data-active={active || undefined}>
<span className={styles.range}>
{ru(tier.min)} {ru(tier.max)}
</span>
<span className={styles.pct}>{tier.pct}%</span>
</div>
)
})}
</>
)
}

View File

@@ -0,0 +1 @@
export { Footer } from './ui/Footer'

View File

@@ -0,0 +1,134 @@
.footer {
padding: 60px 48px 40px;
border-top: 1px solid var(--glass-border);
max-width: 1400px;
margin: 0 auto;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.7;
}
.top {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 48px;
margin-bottom: 48px;
}
.col p,
.col a {
color: var(--text-secondary);
font-size: 13px;
}
.col a {
text-decoration: underline;
display: flex;
margin-bottom: 6px;
transition: color 0.3s;
}
.col a:hover {
color: var(--highlight);
}
.heading {
font-size: 12px;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--text-primary);
margin-bottom: 16px;
font-weight: 600;
}
.companyName {
color: var(--text-primary);
font-weight: 600;
font-size: 15px;
margin-bottom: 12px;
}
.phone {
color: var(--text-primary);
font-weight: 600;
font-size: 16px;
margin-bottom: 16px;
}
.email {
margin-top: 12px !important;
}
.socialIcons {
display: flex;
gap: 12px;
}
.socialLink {
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid var(--glass-border);
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.3s, background 0.3s;
}
.socialLink:hover {
border-color: var(--highlight);
background: rgba(0, 212, 255, 0.06);
}
.socialLink img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
transition: filter 0.3s;
}
.socialLink:hover img {
filter: brightness(0) invert(1) sepia(1) saturate(3) hue-rotate(170deg);
}
.divider {
border-top: 1px solid var(--glass-border);
margin: 0 0 32px;
}
.bottom {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.bottom p {
font-size: 12px;
color: var(--text-secondary);
}
@media (max-width: 1024px) {
.footer {
padding: 40px 32px;
}
.top {
gap: 1.5rem;
grid-template-columns: 1fr 1fr;
margin-bottom: 2rem;
}
}
@media (max-width: 480px) {
.top {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.footer {
padding: 32px 24px;
}
}

View File

@@ -0,0 +1,53 @@
import instagram from '@shared/assets/instagram.svg'
import telegram from '@shared/assets/telegram.svg'
import whatsapp from '@shared/assets/whatsapp.svg'
import styles from './Footer.module.css'
const SOCIALS = [
{ href: '#', icon: telegram, label: 'Telegram' },
{ href: '#', icon: whatsapp, label: 'WhatsApp' },
{ href: '#', icon: instagram, label: 'Instagram' },
]
export function Footer() {
return (
<footer className={styles.footer}>
<div className={styles.top}>
<div className={styles.col}>
<p className={styles.companyName}>ООО «ЭКСА»</p>
<p>ИНН 9810001062</p>
<p>ОГРН 1257800060990</p>
</div>
<div className={styles.col}>
<h4 className={styles.heading}>О компании</h4>
<a href="#">Документы</a>
<a href="#">Публичная оферта</a>
<a href="#">Реквизиты</a>
</div>
<div className={styles.col}>
<p className={styles.phone}>+7 (812) 123-33-23</p>
<h4 className={styles.heading}>Адрес</h4>
<p>196158, г. Санкт-Петербург, Московское шоссе, 25А, к.1, ПОМЕЩ. 3-Н</p>
<a href="mailto:company@elcsa.ru" className={styles.email}>
company@elcsa.ru
</a>
</div>
<div className={styles.col}>
<h4 className={styles.heading}>Мы в соцсетях</h4>
<div className={styles.socialIcons}>
{SOCIALS.map(({ href, icon, label }) => (
<a key={label} href={href} className={styles.socialLink} aria-label={label}>
<img src={icon} alt={label} />
</a>
))}
</div>
</div>
</div>
<div className={styles.divider} />
<div className={styles.bottom}>
<p>© 2026. Все права защищены.</p>
<p>Компания не является кредитной организацией.</p>
</div>
</footer>
)
}

View File

@@ -0,0 +1 @@
export { Header } from './ui/Header'

View File

@@ -0,0 +1,73 @@
.nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 48px;
backdrop-filter: blur(20px);
background: rgba(10, 11, 46, 0.7);
border-bottom: 1px solid var(--glass-border);
}
.logo img {
height: 48px;
width: 80px;
display: block;
}
.right {
display: flex;
align-items: center;
gap: 32px;
}
.link {
font-size: 14px;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.3s;
}
.link:hover {
color: var(--highlight);
}
@media (max-width: 550px) {
.link {
display: none;
}
}
.btn {
padding: 10px 28px;
border: 1px solid var(--text-primary);
border-radius: 100px;
background: transparent;
color: var(--text-primary);
font-size: 14px;
letter-spacing: 1px;
transition: all 0.3s;
}
.btn:hover {
background: var(--text-primary);
color: var(--bg-deep);
}
@media (max-width: 1024px) {
.nav {
padding: 10px 32px;
}
}
@media (max-width: 640px) {
.nav {
padding: 16px 24px;
}
}

Some files were not shown because too many files have changed in this diff Show More