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

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;
}
}

View File

@@ -0,0 +1,22 @@
import logo from '@shared/assets/logo-full-white.png'
import styles from './Header.module.css'
import { Link } from 'react-router-dom'
import { ROUTES } from '@shared/config/routes'
export function Header() {
return (
<nav className={styles.nav}>
<a className={styles.logo} href="/">
<img src={logo} alt="ЭКСА" />
</a>
<div className={styles.right}>
<a className={styles.link} href="#about">
О нас
</a>
<Link className={styles.btn} to={ROUTES.WALLET}>
Личный кабинет
</Link>
</div>
</nav>
)
}

View File

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

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react'
const STORAGE_KEY = 'eksa_countdown_target'
export interface CountdownValue {
d: string
h: string
m: string
s: string
}
export function useCountdown(days: number): CountdownValue {
const [target] = useState<number>(() => {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) return Number.parseInt(saved, 10)
const t = Date.now() + days * 86_400_000
localStorage.setItem(STORAGE_KEY, String(t))
return t
})
const [now, setNow] = useState<number>(Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [])
const diff = Math.max(0, target - now)
const pad = (n: number) => String(n).padStart(2, '0')
return {
d: pad(Math.floor(diff / 86_400_000)),
h: pad(Math.floor((diff % 86_400_000) / 3_600_000)),
m: pad(Math.floor((diff % 3_600_000) / 60_000)),
s: pad(Math.floor((diff % 60_000) / 1000)),
}
}

View File

@@ -0,0 +1,145 @@
.flow {
position: relative;
width: 420px;
height: 460px;
}
.card {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(12px);
border-radius: 20px;
z-index: 2;
}
.cardRub {
width: 100px;
height: 100px;
top: 0;
left: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 0 24px rgba(255, 255, 255, 0.15);
}
.cardEksa {
width: 120px;
height: 120px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.cardUsdt {
width: 100px;
height: 100px;
bottom: 0;
right: 0;
background: rgba(38, 161, 123, 0.12);
border: 1px solid rgba(38, 161, 123, 0.3);
box-shadow: 0 0 24px rgba(38, 161, 123, 0.25);
}
.eksaLogo {
opacity: 0.9;
height: 100px;
width: 80px;
object-fit: contain;
}
.ghost {
position: absolute;
width: 64px;
height: 64px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.2;
z-index: 1;
backdrop-filter: blur(8px);
}
.ghostBtc {
top: 10px;
right: 40px;
}
.ghostEth {
bottom: 30px;
left: 10px;
}
.logoCircle {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 700;
}
.logoRub {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.logoUsdt {
background: rgba(38, 161, 123, 0.25);
color: var(--success);
}
.badge {
position: absolute;
bottom: -8px;
right: -8px;
background: var(--bg-mid);
border: 1px solid var(--glass-border);
border-radius: 8px;
padding: 3px 8px;
font-size: 11px;
font-family: var(--font-mono);
white-space: nowrap;
}
.badgeRub {
color: var(--text-secondary);
}
.badgeUsdt {
color: var(--success);
}
.status {
position: absolute;
bottom: -30px;
right: 0;
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--success);
}
.statusDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
}
.path {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
}

View File

@@ -0,0 +1,68 @@
import logo from '@shared/assets/logo-full-white.png'
import styles from './ConversionFlow.module.css'
export function ConversionFlow() {
return (
<div className={styles.flow}>
<div className={`${styles.ghost} ${styles.ghostBtc}`}>
<span style={{ fontSize: 20, color: '#F7931A' }}></span>
</div>
<div className={`${styles.ghost} ${styles.ghostEth}`}>
<span style={{ fontSize: 20, color: '#627EEA' }}>Ξ</span>
</div>
<div className={`${styles.card} ${styles.cardRub}`}>
<div className={`${styles.logoCircle} ${styles.logoRub}`}></div>
<div className={`${styles.badge} ${styles.badgeRub}`}>10 000 </div>
</div>
<div className={`${styles.card} ${styles.cardEksa}`}>
<img src={logo} alt="ЭКСА" className={styles.eksaLogo} />
</div>
<div className={`${styles.card} ${styles.cardUsdt}`}>
<div className={`${styles.logoCircle} ${styles.logoUsdt}`}></div>
<div className={`${styles.badge} ${styles.badgeUsdt}`}> 125.3 USDT</div>
<div className={styles.status}>
<span className={styles.statusDot} /> Зачислено
</div>
</div>
<svg
className={styles.path}
viewBox="0 0 420 460"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<linearGradient id="pathGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="rgba(255,255,255,0.4)" />
<stop offset="100%" stopColor="#26A17B" />
</linearGradient>
</defs>
<path
d="M60 68 C100 160, 250 140, 210 230 C170 310, 300 320, 350 360"
stroke="url(#pathGrad)"
strokeWidth={2}
strokeDasharray="8 6"
fill="none"
/>
<path
d="M60 68 C100 160, 250 140, 210 230 C170 310, 300 320, 350 360"
stroke="url(#pathGrad)"
strokeWidth={8}
strokeDasharray="8 6"
opacity={0.08}
fill="none"
/>
<circle cx={80} cy={110} r={3} fill="#fff" opacity={0.5} />
<circle cx={140} cy={160} r={2.5} fill="#fff" opacity={0.4} />
<circle cx={220} cy={200} r={3} fill="#fff" opacity={0.3} />
<circle cx={200} cy={270} r={2.5} fill="#26A17B" opacity={0.35} />
<circle cx={260} cy={310} r={3} fill="#26A17B" opacity={0.25} />
<circle cx={320} cy={345} r={2} fill="#26A17B" opacity={0.2} />
</svg>
</div>
)
}

View File

@@ -0,0 +1,40 @@
.label {
font-size: 13px;
letter-spacing: 4px;
text-transform: uppercase;
color: var(--text-secondary);
}
.row {
display: flex;
gap: 24px;
margin-top: 16px;
}
.unit {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.num {
font-family: var(--font-mono);
font-size: clamp(48px, 5vw, 72px);
font-weight: 700;
line-height: 1;
letter-spacing: 2px;
}
.lbl {
font-size: 11px;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--text-secondary);
}
@media (max-width: 640px) {
.row {
gap: 16px;
}
}

View File

@@ -0,0 +1,31 @@
import { useCountdown } from '../lib/useCountdown'
import styles from './Countdown.module.css'
const UNITS: Array<['d' | 'h' | 'm' | 's', string]> = [
['d', 'ДНЕЙ'],
['h', 'ЧАСОВ'],
['m', 'МИНУТ'],
['s', 'СЕКУНД'],
]
interface Props {
days: number
}
export function Countdown({ days }: Props) {
const cd = useCountdown(days)
return (
<div>
<div className={styles.label}>ДО ЗАПУСКА ОСТАЛОСЬ</div>
<div className={styles.row}>
{UNITS.map(([key, label]) => (
<div key={key} className={styles.unit}>
<div className={styles.num}>{cd[key]}</div>
<div className={styles.lbl}>{label}</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,270 @@
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* Анимация 1: поток по пунктиру стрелок */
@keyframes flowDash {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: -7;
}
/* период = dasharray 4+3 */
}
/* Анимация 2: поочерёдный glow на иконках */
@keyframes glowRub {
0%,
100% {
box-shadow: 0 0 20px rgba(74, 109, 255, 0.15);
}
50% {
box-shadow: 0 0 32px rgba(74, 109, 255, 0.55), 0 0 8px rgba(74, 109, 255, 0.3);
}
}
@keyframes glowUsdt {
0%,
100% {
box-shadow: 0 0 20px rgba(38, 161, 123, 0.15);
}
50% {
box-shadow: 0 0 32px rgba(38, 161, 123, 0.55), 0 0 8px rgba(38, 161, 123, 0.3);
}
}
.card {
width: 100%;
max-width: 100%;
background: linear-gradient(160deg, rgba(27, 21, 71, 0.95), rgba(10, 11, 46, 0.98));
border: 1px solid var(--glass-border);
border-radius: 24px;
padding: 28px 24px 24px;
position: relative;
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
top: -60px;
right: -60px;
width: 180px;
height: 180px;
background: radial-gradient(circle, rgba(91, 61, 184, 0.2), transparent 70%);
pointer-events: none;
}
.card::after {
content: '';
position: absolute;
bottom: -40px;
left: -40px;
width: 140px;
height: 140px;
background: radial-gradient(circle, rgba(38, 161, 123, 0.1), transparent 70%);
pointer-events: none;
}
.statusPill {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(38, 161, 123, 0.1);
border: 1px solid rgba(38, 161, 123, 0.2);
border-radius: 999px;
padding: 5px 14px 5px 10px;
font-size: 12px;
font-weight: 600;
color: var(--success);
margin-bottom: 20px;
letter-spacing: 0.5px;
}
.statusDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
animation: pulse 2s ease-in-out infinite;
}
.flowRow {
display: flex;
align-items: center;
position: relative;
z-index: 1;
}
.curBlock {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.curIcon {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
}
.rub {
background: rgba(74, 109, 255, 0.12);
border: 1.5px solid rgba(74, 109, 255, 0.3);
color: #8ba3ff;
box-shadow: 0 0 20px rgba(74, 109, 255, 0.15);
animation: glowRub 2s ease-in-out infinite;
}
.usdt {
background: rgba(38, 161, 123, 0.12);
border: 1.5px solid rgba(38, 161, 123, 0.3);
color: var(--success);
box-shadow: 0 0 20px rgba(38, 161, 123, 0.15);
animation: glowUsdt 2s ease-in-out infinite 1s;
}
/* Анимированный пунктир на стрелках */
.flowLine {
animation: flowDash 1s linear infinite;
}
.curAmount {
font-family: var(--font-mono);
font-size: 18px;
font-weight: 700;
letter-spacing: -0.5px;
color: var(--text-primary);
text-align: center;
}
.curLabel {
font-size: 12px;
color: var(--text-secondary);
letter-spacing: 1px;
text-transform: uppercase;
margin-top: -4px;
}
.curCheck {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--success);
font-weight: 600;
margin-top: -2px;
letter-spacing: 0.3px;
}
.bridge {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 0 8px;
}
.bridgeLine {
display: flex;
align-items: center;
color: var(--text-secondary);
opacity: 0.7;
}
.bridgeLabel {
font-size: 10px;
font-weight: 700;
letter-spacing: 2px;
color: var(--text-secondary);
text-transform: uppercase;
}
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, var(--glass-border), transparent);
margin: 20px 0 16px;
}
.details {
display: flex;
flex-direction: column;
gap: 8px;
position: relative;
z-index: 1;
}
.detailRow {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.02);
border-radius: 10px;
}
.detailLabel {
font-size: 12px;
color: var(--text-secondary);
letter-spacing: 0.5px;
}
.detailValue {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.green {
color: var(--success);
}
@media (max-width: 1024px) {
.bridgeLabel img {
width: 80px;
}
}
@media (max-width: 550px) {
.bridgeLabel img {
display: none;
}
.bridgeLabel+.bridgeLine {
display: none;
}
.curAmount {
font-size: 16px;
}
.curIcon {
width: 48px;
height: 48px;
font-size: 20px;
}
}

View File

@@ -0,0 +1,84 @@
import { USDT_RATE } from '@shared/config/constants'
import logo from '@shared/assets/logo-full-white.png'
import styles from './ExchangeCard.module.css'
export function ExchangeCard() {
return (
<div className={styles.card}>
<div className={styles.statusPill}>
<span className={styles.statusDot} />
Операция завершена
</div>
<div className={styles.flowRow}>
<div className={styles.curBlock}>
<div className={`${styles.curIcon} ${styles.rub}`}></div>
<div className={styles.curAmount}>10 000 </div>
<div className={styles.curLabel}>Отправлено</div>
</div>
<div className={styles.bridge}>
<div className={styles.bridgeLine}>
<svg width={48} height={12} viewBox="0 0 48 12" fill="none" aria-hidden="true">
<line
x1={0} y1={6} x2={36} y2={6}
stroke="currentColor" strokeWidth={1.5} strokeDasharray="4 3"
className={styles.flowLine}
/>
<path
d="M34 2l6 4-6 4"
stroke="currentColor" strokeWidth={1.5}
strokeLinecap="round" strokeLinejoin="round" fill="none"
/>
</svg>
</div>
<div className={styles.bridgeLabel}>
<img src={logo} alt="ЭКСА" />
</div>
<div className={styles.bridgeLine}>
<svg width={48} height={12} viewBox="0 0 48 12" fill="none" aria-hidden="true">
<line
x1={0} y1={6} x2={36} y2={6}
stroke="currentColor" strokeWidth={1.5} strokeDasharray="4 3"
className={styles.flowLine}
/>
<path
d="M34 2l6 4-6 4"
stroke="currentColor" strokeWidth={1.5}
strokeLinecap="round" strokeLinejoin="round" fill="none"
/>
</svg>
</div>
</div>
<div className={styles.curBlock}>
<div className={`${styles.curIcon} ${styles.usdt}`}></div>
<div className={styles.curAmount}>125.3 USDT</div>
<div className={styles.curCheck}>
<svg width={12} height={12} viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path
d="M2 6l3 3 5-5"
stroke="#26A17B" strokeWidth={1.5}
strokeLinecap="round" strokeLinejoin="round"
/>
</svg>
Зачислено
</div>
</div>
</div>
<div className={styles.divider} />
<div className={styles.details}>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Курс</span>
<span className={styles.detailValue}>{USDT_RATE.toFixed(2)} / USDT</span>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Комиссия</span>
<span className={`${styles.detailValue} ${styles.green}`}>0%</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,159 @@
.hero {
min-height: 100vh;
position: relative;
display: flex;
align-items: center;
padding: 120px 48px 80px;
overflow: hidden;
background: var(--bg-deep);
}
.hero::after {
content: '';
position: absolute;
width: 500px;
height: 500px;
left: 15%;
top: 30%;
background: radial-gradient(circle, rgba(91, 61, 184, 0.25), transparent 70%);
pointer-events: none;
}
.hero::before {
content: '';
position: absolute;
width: 400px;
height: 400px;
right: 20%;
bottom: 10%;
background: radial-gradient(circle, rgba(61, 42, 142, 0.18), transparent 70%);
pointer-events: none;
}
.content {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 60px;
align-items: center;
width: 100%;
max-width: 1400px;
margin: 0 auto;
}
.left {
display: flex;
flex-direction: column;
gap: 32px;
align-items: center;
}
.desktop {
display: block;
}
.mobile {
display: none;
}
.right {
display: flex;
flex-direction: column;
gap: 40px;
align-items: flex-start;
}
.title {
font-size: clamp(48px, 5vw, 72px);
font-weight: 700;
line-height: 1.05;
letter-spacing: -1px;
}
.line2 {
font-size: clamp(60px, 7vw, 96px);
font-weight: 700;
background: linear-gradient(135deg, var(--text-primary) 30%, var(--grad-center));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
display: block;
line-height: 1;
}
.reflection {
display: block;
font-size: clamp(48px, 5vw, 72px);
transform: scaleY(-1);
opacity: 0.08;
-webkit-text-fill-color: var(--text-primary);
background: none;
line-height: 1;
margin-top: 4px;
}
.cta {
display: inline-block;
margin-top: 28px;
padding: 16px 40px;
border-radius: 100px;
background: var(--grad-center);
color: #fff;
font-size: 15px;
font-weight: 600;
letter-spacing: 1px;
transition: all 0.3s;
}
.cta:hover {
background: var(--interactive);
box-shadow: 0 4px 24px rgba(74, 109, 255, 0.3);
}
@media (max-width: 1024px) {
.hero {
min-height: min(100vh, 1000px);
}
.content {
grid-template-columns: 1fr;
gap: 3rem;
}
.left {
display: flex;
justify-content: center;
order: 2;
}
.desktop {
display: none;
}
.mobile {
display: block;
width: 100%;
}
.right {
align-items: center;
text-align: center;
justify-content: center;
}
.reflection {
display: none;
}
.hero {
padding: 100px 32px 40px;
}
}
@media (max-width: 640px) {
.hero {
padding-left: 24px;
padding-right: 24px;
}
}

View File

@@ -0,0 +1,43 @@
import { COUNTDOWN_DAYS } from '@shared/config/constants'
import { ConversionFlow } from './ConversionFlow'
import { Countdown } from './Countdown'
import { ExchangeCard } from './ExchangeCard'
import styles from './Hero.module.css'
export function Hero() {
return (
<section className={styles.hero}>
<div className={styles.content}>
<div className={styles.left}>
<div className={styles.desktop}>
<ConversionFlow />
</div>
<div className={styles.mobile}>
<ExchangeCard />
</div>
</div>
<div className={styles.right}>
<h1 className={styles.title}>
Ваш мост
<span className={styles.reflection} aria-hidden="true">
Ваш мост
</span>
<span className={styles.line2}>
в мир
<br />
цифровых
<br />
активов
</span>
</h1>
<div>
<Countdown days={COUNTDOWN_DAYS} />
<a href="#converter" className={styles.cta}>
Попробовать калькулятор
</a>
</div>
</div>
</div>
</section>
)
}

View File

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

View File

@@ -0,0 +1,86 @@
.card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 24px;
padding: 48px 40px;
width: 100%;
max-width: 480px;
}
.logo {
display: flex;
justify-content: center;
margin-bottom: 28px;
}
.logo img {
height: 40px;
}
.title {
text-align: center;
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 24px;
line-height: 1.3;
}
.fields {
display: flex;
flex-direction: column;
gap: 12px;
}
.forgot {
display: block;
text-align: right;
font-size: 13px;
color: var(--interactive);
margin: 8px 0 16px;
cursor: pointer;
text-decoration: none;
transition: color 0.2s;
}
.forgot:hover {
color: var(--highlight);
}
.actions {
display: flex;
flex-direction: column;
gap: 0;
}
.divider {
display: flex;
align-items: center;
gap: 16px;
margin: 24px 0;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--glass-border);
}
.divider span {
font-size: 13px;
color: var(--text-secondary);
}
@media (max-width: 520px) {
.card {
padding: 32px 20px;
min-height: 100vh;
min-width: 100%;
border-radius: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
}

View File

@@ -0,0 +1,52 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FormField } from '@shared/ui'
import { PrimaryButton } from '@shared/ui'
import { Button } from '@shared/ui'
import { ROUTES } from '@shared/config/routes'
import logo from '@shared/assets/logo-full-white.png'
import styles from './LoginForm.module.css'
export function LoginForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const navigate = useNavigate()
return (
<form className={styles.card}>
<div className={styles.logo}>
<img src={logo} alt="ЭКСА" />
</div>
<h1 className={styles.title}>Войти в кошелёк ЭКСА</h1>
<div className={styles.fields}>
<FormField
label="Адрес электронной почты"
type="email"
value={email}
onChange={setEmail}
placeholder="example@mail.ru"
required
/>
<FormField
label="Пароль"
type="password"
value={password}
onChange={setPassword}
placeholder="••••••••"
required
/>
</div>
<a className={styles.forgot}>Забыли пароль?</a>
<div className={styles.actions}>
<PrimaryButton label="Войти" />
<div className={styles.divider}><span>или</span></div>
<Button variant="outline" onClick={() => navigate(ROUTES.REGISTER)}>
Создать новый кошелёк
</Button>
</div>
</form>
)
}

View File

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

View File

@@ -0,0 +1,63 @@
export interface Network {
name: string
ticker: string
cls: 'btc' | 'eth' | 'trx' | 'sol' | 'bnb'
icon: string
speed: number
color: string
fee: string
confirm: string
}
export const NETWORKS: readonly Network[] = [
{
name: 'Bitcoin',
ticker: 'BTC',
cls: 'btc',
icon: '₿',
speed: 30,
color: 'rgba(247,147,26,0.8)',
fee: '~0.0001 BTC',
confirm: '~10 мин',
},
{
name: 'Ethereum',
ticker: 'ETH',
cls: 'eth',
icon: 'Ξ',
speed: 60,
color: 'rgba(98,126,234,0.8)',
fee: '~215 Gwei',
confirm: '~15 сек',
},
{
name: 'Tron',
ticker: 'TRX',
cls: 'trx',
icon: '◈',
speed: 90,
color: 'rgba(255,6,10,0.8)',
fee: '~1 TRX',
confirm: '~3 сек',
},
{
name: 'Solana',
ticker: 'SOL',
cls: 'sol',
icon: '◎',
speed: 98,
color: 'rgba(153,69,255,0.8)',
fee: '~0.000005 SOL',
confirm: '~1 сек',
},
{
name: 'BNB Chain',
ticker: 'BNB',
cls: 'bnb',
icon: '◆',
speed: 88,
color: 'rgba(243,186,47,0.8)',
fee: '~0.0005 BNB',
confirm: '~3 сек',
},
] as const

View File

@@ -0,0 +1,156 @@
.section {
padding: 100px 48px;
background: var(--bg-deep);
}
.wrap {
max-width: 1200px;
margin: 0 auto;
}
.title {
font-size: clamp(32px, 3.5vw, 48px);
font-weight: 700;
margin-bottom: 56px;
text-align: center;
}
.tableWrap {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px;
padding: 32px;
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th {
text-align: left;
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--text-secondary);
font-weight: 500;
padding: 0 16px 20px;
}
.table td {
padding: 18px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
vertical-align: middle;
transition: background 0.2s ease;
}
.table tr:hover td {
background: rgba(255, 255, 255, 0.04);
}
.name {
display: flex;
align-items: center;
gap: 12px;
}
.icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.icon_btc {
background: linear-gradient(135deg, #f7931a, #e8850f);
}
.icon_eth {
background: linear-gradient(135deg, #627eea, #4965d0);
}
.icon_trx {
background: linear-gradient(135deg, #ff0013, #cc000f);
}
.icon_sol {
background: linear-gradient(135deg, #9945ff, #14f195);
}
.icon_bnb {
background: linear-gradient(135deg, #f3ba2f, #d4a229);
}
.label {
font-weight: 600;
font-size: 15px;
}
.ticker {
color: var(--text-secondary);
font-size: 13px;
margin-left: 6px;
}
.speedBar {
width: 120px;
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.speedFill {
height: 100%;
border-radius: 999px;
}
.fee {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
}
.confirm {
font-size: 13px;
color: var(--text-secondary);
}
.footnote {
margin-top: 20px;
font-size: 11px;
color: var(--text-secondary);
text-align: center;
}
@media (max-width: 1024px) {
.section {
padding: 40px 32px;
}
.title {
margin-bottom: 2rem;
}
.tableWrap {
padding: 24px;
}
.table td {
text-wrap: nowrap;
}
}
@media (max-width: 640px) {
.section {
padding-left: 24px;
padding-right: 24px;
}
}

View File

@@ -0,0 +1,54 @@
import { NETWORKS } from '../model/networks'
import styles from './NetworksTable.module.css'
export function NetworksTable() {
return (
<section className={styles.section}>
<div className={styles.wrap}>
<h2 className={styles.title}>Поддерживаемые сети</h2>
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Сеть</th>
<th>Скорость</th>
<th>Комиссия</th>
<th>Подтверждение</th>
</tr>
</thead>
<tbody>
{NETWORKS.map((n) => (
<tr key={n.ticker}>
<td>
<div className={styles.name}>
<div className={`${styles.icon} ${styles[`icon_${n.cls}`]}`}>{n.icon}</div>
<span className={styles.label}>{n.name}</span>
<span className={styles.ticker}>{n.ticker}</span>
</div>
</td>
<td>
<div className={styles.speedBar}>
<div
className={styles.speedFill}
style={{ width: `${n.speed}%`, background: n.color }}
/>
</div>
</td>
<td>
<span className={styles.fee}>{n.fee}</span>
</td>
<td>
<span className={styles.confirm}>{n.confirm}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className={styles.footnote}>
* Комиссии и время подтверждения указаны приблизительно и зависят от загруженности сети
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,2 @@
export { ProfileAvatar } from './ui/ProfileAvatar'
export { ProfileSection } from './ui/ProfileSection'

View File

@@ -0,0 +1,84 @@
.col {
width: 200px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.avatar {
width: 150px;
height: 150px;
border-radius: 50%;
background: linear-gradient(135deg, var(--grad-edge), var(--grad-center));
border: 2px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
cursor: pointer;
color: var(--text-secondary);
}
.avatar svg {
width: 54px;
height: 54px;
}
.overlay {
position: absolute;
inset: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.avatar:hover .overlay {
opacity: 1;
}
.overlay svg {
width: 40px;
height: 40px;
}
.col button {
width: 100%;
}
.addPhoto {
width: 100%;
}
@media (max-width: 1023px) {
.addPhoto {
display: none;
}
}
@media (max-width: 549px) {
.col {
width: 90px;
}
.avatar {
width: 90px;
height: 90px;
}
.avatar svg {
width: 32px;
height: 32px;
}
.overlay svg {
width: 22px;
height: 22px;
}
}

View File

@@ -0,0 +1,25 @@
import { Button } from '@shared/ui'
import styles from './ProfileAvatar.module.css'
export function ProfileAvatar() {
return (
<div className={styles.col}>
<div className={styles.avatar}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 4-7 8-7s8 3 8 7" />
</svg>
<div className={styles.overlay}>
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
</div>
</div>
<div className={styles.addPhoto}>
<Button variant="ghost">ДОБАВИТЬ ФОТО</Button>
</div>
<Button variant="danger">УДАЛИТЬ ФОТО</Button>
</div>
)
}

View File

@@ -0,0 +1,36 @@
.card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 20px;
padding: 24px;
}
.title {
font-size: 16px;
color: var(--text-secondary);
font-variant: all-small-caps;
letter-spacing: 0.1em;
font-weight: 700;
margin-bottom: 16px;
}
.actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
margin-top: 20px;
}
@media (max-width: 649px) {
.card {
padding: 16px;
}
}
@media (max-width: 499px) {
.actions {
flex-direction: column;
align-items: stretch;
}
}

View File

@@ -0,0 +1,17 @@
import styles from './ProfileSection.module.css'
interface Props {
title: string
children: React.ReactNode
actions?: React.ReactNode
}
export function ProfileSection({ title, children, actions }: Props) {
return (
<div className={styles.card}>
<div className={styles.title}>{title}</div>
{children}
{actions && <div className={styles.actions}>{actions}</div>}
</div>
)
}

View File

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

View File

@@ -0,0 +1,64 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { registrationStart, registrationComplete } from '@features/auth'
import { ROUTES } from '@shared/config/routes'
import type { ApiErrorResponse } from '@shared/api/types'
function extractErrorMessage(error: unknown): string {
const apiError = error as ApiErrorResponse
return apiError?.detail?.[0]?.msg ?? 'Произошла ошибка'
}
export function useRegisterForm() {
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [verificationCode, setVerificationCode] = useState('')
const [codeSent, setCodeSent] = useState(false)
const [passwordError, setPasswordError] = useState<string | null>(null)
const startMutation = useMutation({
mutationFn: registrationStart,
onSuccess: () => setCodeSent(true),
})
const completeMutation = useMutation({
mutationFn: registrationComplete,
onSuccess: () => navigate(ROUTES.LOGIN),
})
const handleRequestCode = () => {
if (!email) return
startMutation.mutate({ email })
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (password !== confirmPassword) {
setPasswordError('Пароли не совпадают')
return
}
setPasswordError(null)
completeMutation.mutate({ email, password, confirm_password: confirmPassword, code: verificationCode })
}
const error =
passwordError ??
(startMutation.isError ? extractErrorMessage(startMutation.error) : null) ??
(completeMutation.isError ? extractErrorMessage(completeMutation.error) : null)
return {
email, setEmail,
password, setPassword,
confirmPassword, setConfirmPassword,
verificationCode, setVerificationCode,
codeSent,
isLoadingCode: startMutation.isPending,
isLoadingSubmit: completeMutation.isPending,
error,
handleRequestCode,
handleSubmit,
}
}

View File

@@ -0,0 +1,101 @@
.card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 24px;
padding: 32px;
width: 100%;
max-width: 600px;
}
.logo {
display: flex;
justify-content: center;
margin-bottom: 28px;
}
.logo img {
height: 40px;
}
.title {
text-align: center;
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 24px;
line-height: 1.3;
}
.twoCol {
display: grid;
grid-template-columns: 1fr;
gap: 20px 24px;
align-items: start;
}
.leftCol {
display: flex;
flex-direction: column;
gap: 20px;
}
.rightCol {
display: flex;
flex-direction: column;
gap: 8px;
}
.spacer {
height: 18px;
flex-shrink: 0;
}
.codeHint {
font-size: 12px;
color: var(--text-secondary);
text-decoration: underline;
cursor: pointer;
}
.error {
color: #ff5a5a;
font-size: 13px;
margin-top: 12px;
text-align: center;
}
.submitWrapper {
margin-top: 28px;
}
.legal {
text-align: center;
font-size: 11px;
color: var(--text-secondary);
margin-top: 20px;
line-height: 1.6;
}
.legal a {
color: var(--interactive);
text-decoration: none;
}
.legal a:hover {
text-decoration: underline;
}
@media (max-width: 560px) {
.card {
padding: 32px 20px;
border-radius: 0;
}
.twoCol {
grid-template-columns: 1fr;
}
.spacer {
display: none;
}
}

View File

@@ -0,0 +1,90 @@
import { FormField } from '@shared/ui'
import { PrimaryButton } from '@shared/ui'
import { Button } from '@shared/ui'
import logo from '@shared/assets/logo-full-white.png'
import { useRegisterForm } from '../model/useRegisterForm'
import styles from './RegisterForm.module.css'
export function RegisterForm() {
const {
email, setEmail,
password, setPassword,
confirmPassword, setConfirmPassword,
verificationCode, setVerificationCode,
codeSent,
isLoadingCode,
isLoadingSubmit,
error,
handleRequestCode,
handleSubmit,
} = useRegisterForm()
return (
<form className={styles.card} onSubmit={handleSubmit}>
<div className={styles.logo}>
<img src={logo} alt="ЭКСА" />
</div>
<h1 className={styles.title}>Создать кошелёк ЭКСА</h1>
<div className={styles.twoCol}>
<div className={styles.leftCol}>
<FormField
label="Введите адрес электронной почты"
type="email"
value={email}
onChange={setEmail}
placeholder="example@mail.ru"
required
/>
<FormField
label="Придумайте пароль"
type="password"
value={password}
onChange={setPassword}
placeholder="••••••••"
required
/>
<FormField
label="Повторите пароль"
type="password"
value={confirmPassword}
onChange={setConfirmPassword}
placeholder="••••••••"
required
/>
</div>
<div className={styles.rightCol}>
<Button
variant="ghost"
type="button"
onClick={handleRequestCode}
disabled={codeSent || isLoadingCode}
>
{isLoadingCode ? 'Отправка...' : codeSent ? 'Код отправлен' : 'Получить проверочный код'}
</Button>
<span className={styles.codeHint}>Код не пришёл</span>
<FormField
label="Ввести код"
type="text"
value={verificationCode}
onChange={setVerificationCode}
placeholder="000 000"
required
/>
</div>
</div>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.submitWrapper}>
<PrimaryButton label={isLoadingSubmit ? 'Создание...' : 'Создать'} disabled={isLoadingSubmit} />
</div>
<p className={styles.legal}>
Нажимая «Создать», вы принимаете<br />
<a href="#">Пользовательское соглашение</a> и <a href="#">Политику конфиденциальности</a>
</p>
</form>
)
}

View File

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

View File

@@ -0,0 +1,52 @@
import { useCallback, useEffect, useRef, useState } from 'react'
const INITIAL_COUNTDOWN = 52
export function useSeedPhrase(words: string[]) {
const [hidden, setHidden] = useState(false)
const [countdown, setCountdown] = useState(INITIAL_COUNTDOWN)
const [copied, setCopied] = useState(false)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const startCountdown = useCallback(() => {
if (intervalRef.current) clearInterval(intervalRef.current)
setCountdown(INITIAL_COUNTDOWN)
intervalRef.current = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
clearInterval(intervalRef.current!)
setHidden(true)
return 0
}
return prev - 1
})
}, 1000)
}, [])
useEffect(() => {
startCountdown()
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [startCountdown])
const handleHide = useCallback(() => {
setHidden(prev => {
if (prev) {
startCountdown()
return false
}
if (intervalRef.current) clearInterval(intervalRef.current)
return true
})
}, [startCountdown])
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(words.join(' ')).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}, [words])
return { hidden, countdown, copied, handleHide, handleCopy }
}

View File

@@ -0,0 +1,130 @@
.content {
max-width: 960px;
margin: 0 auto;
}
.titleRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
height: 70px;
}
.title {
font-size: 32px;
font-weight: 700;
letter-spacing: 0.04em;
}
.titleButtons {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
}
.btnFixed {
width: 160px;
}
.btnFixed > * {
width: 100%;
}
.subtitle {
margin-top: 12px;
font-size: 14px;
color: var(--text-secondary);
font-variant: all-small-caps;
letter-spacing: 0.08em;
}
.countdown {
color: var(--interactive);
font-weight: 700;
}
.seedGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-top: 32px;
}
.seedCard {
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border);
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;
}
.seedCard:hover {
border-color: rgba(74, 109, 255, 0.4);
box-shadow: 0 0 12px rgba(74, 109, 255, 0.15);
}
.seedNum {
color: var(--text-secondary);
font-size: 13px;
min-width: 22px;
flex-shrink: 0;
}
.seedWord {
flex: 1;
text-align: center;
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.seedWordHidden {
color: var(--text-secondary);
letter-spacing: 0.15em;
}
.warning {
margin-top: 32px;
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: center;
}
.warningIcon {
font-size: 18px;
padding: 16px;
flex-shrink: 0;
}
.warningText {
max-width: 480px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
padding-top: 14px;
}
@media (max-width: 768px) {
.seedGrid {
grid-template-columns: repeat(2, 1fr);
}
.titleRow {
height: auto;
flex-direction: column;
gap: 16px;
}
.titleButtons {
align-items: flex-start;
flex-direction: row;
}
}

View File

@@ -0,0 +1,56 @@
import { Button } from '@shared/ui'
import { useSeedPhrase } from '../model/useSeedPhrase'
import styles from './SeedPhraseWidget.module.css'
interface Props {
words: string[]
}
export function SeedPhraseWidget({ words }: Props) {
const { hidden, countdown, copied, handleHide, handleCopy } = useSeedPhrase(words)
return (
<div className={styles.content}>
<div className={styles.titleRow}>
<h1 className={styles.title}>СИД ФРАЗА</h1>
<div className={styles.titleButtons}>
<div className={styles.btnFixed}>
<Button variant="outline" onClick={handleHide}>
{hidden ? 'ПОКАЗАТЬ' : 'СКРЫТЬ'}
</Button>
</div>
<div className={styles.btnFixed}>
<Button variant="outline" onClick={handleCopy}>
{copied ? 'СКОПИРОВАНО' : 'КОПИРОВАТЬ'}
</Button>
</div>
</div>
</div>
{!hidden && (
<div className={styles.subtitle}>
АВТОМАТИЧЕСКОЕ СКРЫТИЕ ЧЕРЕЗ{' '}
<span className={styles.countdown}>{countdown}</span>С
</div>
)}
<div className={styles.seedGrid}>
{words.map((word, i) => (
<div key={i} className={styles.seedCard}>
<span className={styles.seedNum}>{i + 1}.</span>
<span className={`${styles.seedWord} ${hidden ? styles.seedWordHidden : ''}`}>
{hidden ? '•••••' : word}
</span>
</div>
))}
</div>
<div className={styles.warning}>
<span className={styles.warningIcon}></span>
<p className={styles.warningText}>
Никогда не передавайте сид-фразу третьим лицам. Тот, кто знает фразу владеет кошельком.
</p>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,68 @@
import { useState } from 'react'
import btc from '@shared/assets/btc.svg'
import eth from '@shared/assets/eth.svg'
import sol from '@shared/assets/sol.svg'
import trx from '@shared/assets/trx.svg'
import arb from '@shared/assets/arb.svg'
export interface Token {
symbol: string
letter: string
color: string
logo?: string
network: string
balance: number
usdRate: number
}
const TOKENS: Record<string, Token> = {
BTC: { symbol: 'BTC', letter: '₿', logo: btc, color: '#F7931A', network: 'BITCOIN', balance: 0, usdRate: 67412 },
ETH: { symbol: 'ETH', letter: 'E', logo: eth, color: '#627EEA', network: 'ETHEREUM', balance: 0, usdRate: 3521 },
SOL: { symbol: 'SOL', letter: 'S', logo: sol, color: '#9945FF', network: 'SOLANA', balance: 0.994, usdRate: 163.84 },
TRX: { symbol: 'TRX', letter: 'T', logo: trx, color: '#FF060A', network: 'TRON', balance: 0, usdRate: 0.12 },
ARB: { symbol: 'ARB', letter: 'A', logo: arb, color: '#4A6DFF', network: 'ARBITRUM', balance: 0, usdRate: 0.92 },
USDC: { symbol: 'USDC', letter: '$', color: '#2775CA', network: 'SOLANA', balance: 0, usdRate: 1 },
USDT: { symbol: 'USDT', letter: '$', color: '#26A17B', network: 'ETHEREUM', balance: 0, usdRate: 1 },
}
export const TOKENS_LIST: Token[] = Object.values(TOKENS)
const RATE = 82.2578
export function useSwapForm() {
const [fromAmount, setFromAmountRaw] = useState('0.25')
const [fromToken, setFromToken] = useState<Token>(TOKENS.SOL)
const [toToken, setToToken] = useState<Token>(TOKENS.USDC)
const [isRefreshing, setIsRefreshing] = useState(false)
const fromValue = parseFloat(fromAmount) || 0
const toAmount = (fromValue * RATE).toFixed(4)
const fromUsd = (fromValue * fromToken.usdRate).toFixed(2)
const toUsd = (fromValue * RATE * toToken.usdRate).toFixed(2)
function setFromAmount(v: string) {
setFromAmountRaw(v)
}
function setPercent(p: number) {
setFromAmountRaw((fromToken.balance * p / 100).toFixed(4))
}
function swapTokens() {
setFromToken(toToken)
setToToken(fromToken)
}
function refreshRate() {
setIsRefreshing(true)
setTimeout(() => setIsRefreshing(false), 400)
}
return {
fromAmount, toAmount, fromUsd, toUsd,
fromToken, toToken,
isRefreshing,
setFromAmount, setPercent, swapTokens, refreshRate,
setFromToken, setToToken,
}
}

View File

@@ -0,0 +1,24 @@
.row {
display: flex;
align-items: center;
gap: 8px;
margin: 18px 0 14px;
font-size: 14px;
color: var(--text-secondary);
}
.refresh {
background: none;
border: none;
color: var(--interactive);
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
transition: transform 0.35s;
padding: 0;
}
.refresh:hover {
transform: rotate(180deg) !important;
}

View File

@@ -0,0 +1,25 @@
import styles from './RateRow.module.css'
interface Props {
fromSymbol: string
toSymbol: string
rate: number
isRefreshing: boolean
onRefresh: () => void
}
export function RateRow({ fromSymbol, toSymbol, rate, isRefreshing, onRefresh }: Props) {
return (
<div className={styles.row}>
<span>1 {fromSymbol} = {rate.toFixed(4)} {toSymbol}</span>
<button
className={styles.refresh}
style={{ transform: isRefreshing ? 'rotate(360deg)' : 'rotate(0deg)' }}
onClick={onRefresh}
aria-label="Обновить курс"
>
</button>
</div>
)
}

View File

@@ -0,0 +1,198 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* ── Pills above card (mobile only) ── */
.pillsOuter {
display: none;
gap: 6px;
}
.card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 20px;
padding: 28px;
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
@media (max-width: 650px) {
.top {
margin-bottom: 0;
}
}
.label {
display: flex;
align-items: baseline;
gap: 10px;
}
.tag {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1.2px;
font-weight: 700;
}
.network {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
}
/* ── Pills inside card (desktop only) ── */
.pillsInner {
display: flex;
gap: 6px;
}
.pill {
background: rgba(255, 255, 255, 0.07);
color: var(--text-secondary);
border: none;
border-radius: 999px;
padding: 5px 14px;
font-size: 12px;
cursor: pointer;
font-family: var(--font-sans);
font-weight: 600;
transition: all 0.2s;
}
.pill:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.13);
}
/* ── Select positions ── */
.selectTop {
display: none;
}
.selectMid {
display: flex;
flex-shrink: 0;
}
.mid {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.input {
background: none;
border: none;
outline: none;
font-family: var(--font-sans);
font-size: 64px;
font-weight: 700;
color: var(--text-primary);
width: 100%;
min-width: 0;
}
.input::placeholder {
color: rgba(255, 255, 255, 0.12);
}
.display {
font-weight: 700;
white-space: nowrap;
line-height: 1;
}
.int {
color: var(--text-primary);
font-size: 64px;
}
.dec {
color: var(--text-secondary);
font-size: 36px;
}
.bottom {
display: flex;
align-items: center;
justify-content: space-between;
}
.usd {
font-size: 14px;
color: var(--text-secondary);
}
.neg {
color: var(--error);
}
.balance {
font-size: 13px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 5px;
}
.max {
background: none;
border: none;
color: var(--interactive);
cursor: pointer;
font-weight: 700;
font-size: 13px;
font-family: var(--font-sans);
padding: 0;
}
.max:hover {
text-decoration: underline;
}
/* ── Mobile ── */
@media (max-width: 650px) {
.card {
padding: 16px;
}
.input {
font-size: 36px;
}
.int {
font-size: 36px;
}
.dec {
font-size: 22px;
}
.pillsOuter {
display: flex;
}
.pillsInner {
display: none;
}
.selectTop {
display: flex;
}
.selectMid {
display: none;
}
}

View File

@@ -0,0 +1,101 @@
import type { Token } from '../model/useSwapForm'
import { TokenSelect } from './TokenSelect'
import styles from './SwapCard.module.css'
interface Props {
mode: 'from' | 'to'
token: Token
tokenOptions: Token[]
amount: string
usd: string
slippage?: string
onTokenChange: (token: Token) => void
onAmountChange?: (v: string) => void
onSetPercent?: (p: number) => void
}
const PERCENTS = [25, 50, 100]
export function SwapCard({
mode, token, tokenOptions, amount, usd, slippage,
onTokenChange, onAmountChange, onSetPercent,
}: Props) {
const [intPart, decPart] = amount.split('.')
const pills = onSetPercent && (
<>
{PERCENTS.map(p => (
<button key={p} className={styles.pill} onClick={() => onSetPercent(p)}>
{p}%
</button>
))}
</>
)
return (
<div className={styles.wrapper}>
{/* Пиллы над карточкой — только мобайл */}
{mode === 'from' && pills && (
<div className={styles.pillsOuter}>{pills}</div>
)}
<div className={styles.card}>
<div className={styles.top}>
<div className={styles.label}>
<span className={styles.tag}>{mode === 'from' ? 'ОТ' : 'К'}</span>
<span className={styles.network}>{token.network}</span>
</div>
{/* Пиллы внутри карточки — только десктоп */}
{mode === 'from' && pills && (
<div className={styles.pillsInner}>{pills}</div>
)}
{/* Селект в топ-строке — только мобайл */}
<div className={styles.selectTop}>
<TokenSelect value={token} options={tokenOptions} onChange={onTokenChange} compact />
</div>
</div>
<div className={styles.mid}>
{mode === 'from' ? (
<input
className={styles.input}
type="text"
value={amount}
onChange={e => onAmountChange?.(e.target.value)}
placeholder="0"
/>
) : (
<div className={styles.display}>
<span className={styles.int}>{intPart}</span>
<span className={styles.dec}>.{decPart}</span>
</div>
)}
{/* Селект справа от инпута — только десктоп */}
<div className={styles.selectMid}>
<TokenSelect value={token} options={tokenOptions} onChange={onTokenChange} />
</div>
</div>
<div className={styles.bottom}>
<span className={styles.usd}>
${usd}
{slippage && <span className={styles.neg}> ({slippage})</span>}
</span>
<span className={styles.balance}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="2">
<rect x="2" y="6" width="20" height="14" rx="3" />
<path d="M6 6V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2" />
</svg>
{token.balance.toFixed(mode === 'from' ? 3 : 2)}
{mode === 'from' && onSetPercent && (
<button className={styles.max} onClick={() => onSetPercent(100)}>МАКС</button>
)}
</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
.wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 44px;
margin: 8px 0;
z-index: 2;
}
.line {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: rgba(255, 255, 255, 0.06);
}
.circle {
position: relative;
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--bg-mid);
border: 1px solid rgba(255, 255, 255, 0.12);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--interactive);
font-size: 20px;
transition: background 0.2s, color 0.2s, transform 0.3s;
}
.circle:hover {
background: var(--grad-edge);
color: var(--text-primary);
}

View File

@@ -0,0 +1,30 @@
import { useState } from 'react'
import styles from './SwapDirectionButton.module.css'
interface Props {
onClick: () => void
}
export function SwapDirectionButton({ onClick }: Props) {
const [rotated, setRotated] = useState(false)
function handle() {
setRotated(true)
setTimeout(() => setRotated(false), 300)
onClick()
}
return (
<div className={styles.wrap}>
<div className={styles.line} />
<button
className={styles.circle}
style={{ transform: rotated ? 'rotate(180deg)' : 'rotate(0deg)' }}
onClick={handle}
aria-label="Поменять токены"
>
</button>
</div>
)
}

View File

@@ -0,0 +1,5 @@
.form {
width: 100%;
max-width: 680px;
margin: 0 auto;
}

View File

@@ -0,0 +1,58 @@
import { PrimaryButton } from '@shared/ui'
import { TOKENS_LIST, useSwapForm } from '../model/useSwapForm'
import { RateRow } from './RateRow'
import { SwapCard } from './SwapCard'
import { SwapDirectionButton } from './SwapDirectionButton'
import { SwapInfoPanel } from './SwapInfoPanel'
import styles from './SwapForm.module.css'
const RATE = 82.2578
export function SwapForm() {
const {
fromAmount, toAmount, fromUsd, toUsd,
fromToken, toToken,
isRefreshing,
setFromAmount, setPercent, swapTokens, refreshRate,
setFromToken, setToToken,
} = useSwapForm()
return (
<div className={styles.form}>
<SwapCard
mode="from"
token={fromToken}
tokenOptions={TOKENS_LIST}
amount={fromAmount}
usd={fromUsd}
onAmountChange={setFromAmount}
onSetPercent={setPercent}
onTokenChange={setFromToken}
/>
<SwapDirectionButton onClick={swapTokens} />
<SwapCard
mode="to"
token={toToken}
tokenOptions={TOKENS_LIST}
amount={toAmount}
usd={toUsd}
slippage="0.16%"
onTokenChange={setToToken}
/>
<RateRow
fromSymbol={fromToken.symbol}
toSymbol={toToken.symbol}
rate={RATE}
isRefreshing={isRefreshing}
onRefresh={refreshRate}
/>
<SwapInfoPanel />
<PrimaryButton />
</div>
)
}

View File

@@ -0,0 +1,42 @@
.panel {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 20px;
padding: 4px 24px;
margin-bottom: 24px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.row:last-child {
border-bottom: none;
}
.label {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1.2px;
font-weight: 700;
}
.value {
font-size: 14px;
color: var(--text-primary);
font-weight: 500;
}
.link {
color: var(--interactive);
cursor: pointer;
}
.link:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,20 @@
import styles from './SwapInfoPanel.module.css'
const ROWS = [
{ label: 'ПРОВАЙДЕР', value: 'ЛУЧШИЙ', link: false },
{ label: 'СКОЛЬЖЕНИЕ', value: 'АВТО (0.5%)', link: true },
{ label: 'СЕТЕВОЙ СБОР', value: '$0.10', link: false },
]
export function SwapInfoPanel() {
return (
<div className={styles.panel}>
{ROWS.map(({ label, value, link }) => (
<div key={label} className={styles.row}>
<span className={styles.label}>{label}</span>
<span className={`${styles.value} ${link ? styles.link : ''}`}>{value}</span>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,118 @@
.wrap {
position: relative;
flex-shrink: 0;
}
.trigger {
display: flex;
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.08);
border: none;
border-radius: 14px;
padding: 10px 16px;
cursor: pointer;
transition: background 0.2s;
}
.trigger:hover {
background: rgba(255, 255, 255, 0.15);
}
.name {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-sans);
}
.chevron {
color: var(--text-secondary);
font-size: 14px;
margin-left: 2px;
transition: transform 0.2s;
display: inline-block;
}
.chevronOpen {
transform: rotate(180deg);
}
.dropdown {
max-height: 300px;
overflow: auto;
padding: 8px;
}
.dropdownWrapper {
position: absolute;
overflow: hidden;
top: calc(100% + 8px);
right: 0;
min-width: 200px;
background: var(--bg-mid);
border: 1px solid var(--glass-border);
border-radius: 16px;
z-index: 10;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.option {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
background: none;
border: none;
border-radius: 10px;
padding: 10px 12px;
cursor: pointer;
transition: background 0.15s;
font-family: var(--font-sans);
}
.option:hover {
background: rgba(255, 255, 255, 0.06);
}
.optionActive {
background: rgba(255, 255, 255, 0.04);
}
.optionInfo {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
flex: 1;
}
.optionSymbol {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.optionNetwork {
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.8px;
}
.check {
font-size: 14px;
color: var(--interactive);
font-weight: 700;
}
/* ── Compact variant (mobile top row) ── */
.triggerCompact {
padding: 6px 10px;
border-radius: 10px;
gap: 6px;
}
.nameCompact {
font-size: 15px;
}

View File

@@ -0,0 +1,65 @@
import { useEffect, useRef, useState } from 'react'
import { TokenIcon } from '@shared/ui'
import type { Token } from '../model/useSwapForm'
import styles from './TokenSelect.module.css'
interface Props {
value: Token
options: Token[]
onChange: (token: Token) => void
compact?: boolean
}
export function TokenSelect({ value, options, onChange, compact = false }: Props) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [])
function select(token: Token) {
onChange(token)
setOpen(false)
}
return (
<div className={styles.wrap} ref={ref}>
<button
className={`${styles.trigger} ${compact ? styles.triggerCompact : ''}`}
onClick={() => setOpen(o => !o)}
>
<TokenIcon letter={value.letter} color={value.color} logo={value.logo} size={compact ? 24 : 40} />
<span className={`${styles.name} ${compact ? styles.nameCompact : ''}`}>{value.symbol}</span>
<span className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}></span>
</button>
{open && (
<div className={styles.dropdownWrapper}>
<div className={styles.dropdown}>
{options.map(token => (
<button
key={token.symbol}
className={`${styles.option} ${token.symbol === value.symbol ? styles.optionActive : ''}`}
onClick={() => select(token)}
>
<TokenIcon letter={token.letter} color={token.color} logo={token.logo} size={32} />
<div className={styles.optionInfo}>
<span className={styles.optionSymbol}>{token.symbol}</span>
<span className={styles.optionNetwork}>{token.network}</span>
</div>
{token.symbol === value.symbol && <span className={styles.check}></span>}
</button>
))}
</div>
</div>
)}
</div>
)
}

View File

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

View File

@@ -0,0 +1,25 @@
import btc from '@shared/assets/btc.svg'
import eth from '@shared/assets/eth.svg'
import sol from '@shared/assets/sol.svg'
import trx from '@shared/assets/trx.svg'
import arb from '@shared/assets/arb.svg'
export interface Token {
ticker: string
name: string
logo?: string
color: string
price: string
change: number
bal: string
usd: string
fav: boolean
}
export const TOKENS: readonly Token[] = [
{ ticker: 'BTC', name: 'Bitcoin', logo: btc, color: '#F7931A', price: '$66,916.00', change: 0.12, bal: '0.003295', usd: '$220.57', fav: true },
{ ticker: 'ETH', name: 'Ethereum', logo: eth, color: '#627EEA', price: '$2,053.97', change: -0.12, bal: '0.07636', usd: '$156.51', fav: false },
{ ticker: 'SOL', name: 'Solana', logo: sol, color: '#9945FF', price: '$163.84', change: -1.57, bal: '0.07636', usd: '$156.51', fav: false },
{ ticker: 'TRX', name: 'Tron', logo: trx, color: '#FF060A', price: '$0.1197', change: 1.33, bal: '0.07636', usd: '$156.51', fav: false },
{ ticker: 'ARB', name: 'Arbitrum', logo: arb, color: '#4A6DFF', price: '$0.9214', change: 2.56, bal: '0.07636', usd: '$156.51', fav: false },
] as const

View File

@@ -0,0 +1,294 @@
.wrap {
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border);
border-radius: 20px;
overflow: hidden;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table thead th {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
padding: 14px 20px;
text-align: left;
}
.table tbody tr {
border-top: 1px solid rgba(255, 255, 255, 0.06);
transition: background 0.2s;
}
.table tbody tr:hover {
background: rgba(255, 255, 255, 0.04);
}
.table td {
padding: 14px 20px;
vertical-align: middle;
height: 64px;
}
.thStar {
width: 48px;
}
.right {
text-align: right !important;
}
.center {
text-align: center !important;
width: 140px;
}
.star {
cursor: pointer;
font-size: 18px;
color: rgba(255, 255, 255, 0.3);
background: none;
border: none;
padding: 0;
line-height: 1;
transition: color 0.15s;
}
.star:hover {
color: #f3ba2f;
}
.starOn {
color: #f3ba2f;
}
.tokId {
display: flex;
align-items: center;
gap: 12px;
}
.tokLogo {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 15px;
color: #fff;
flex-shrink: 0;
}
.tokLogo img {
width: 28px;
height: 28px;
}
.arb {
width: 24px;
height: 24px;
}
.tokText b {
font-size: 15px;
display: block;
font-weight: 700;
}
.tokText span {
font-size: 12px;
color: var(--text-secondary);
font-weight: 400;
}
.price {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 500;
}
.change {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 600;
font-family: var(--font-mono);
padding: 4px 10px;
border-radius: 999px;
}
.up {
background: rgba(0, 196, 140, 0.15);
color: var(--success);
}
.dn {
background: rgba(255, 77, 77, 0.15);
color: #ff4d4d;
}
.balCol b {
font-family: var(--font-mono);
font-size: 14px;
display: block;
font-weight: 600;
}
.balCol span {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
}
.sendBtn {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(74, 109, 255, 0.12);
border: 1px solid rgba(74, 109, 255, 0.3);
color: #4a6dff;
border-radius: 10px;
height: 36px;
min-width: 120px;
justify-content: center;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.sendBtn:hover {
background: rgba(74, 109, 255, 0.25);
border-color: #4a6dff;
}
.noFont {
font-family: inherit !important;
}
/* Mobile card list — hidden by default */
.mobileList {
display: none;
}
.card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.card:last-of-type {
border-bottom: none;
}
.cardInfo {
flex: 1;
min-width: 0;
}
.cardTop {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 3px;
}
.cardTicker {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
}
.cardName {
font-size: 12px;
color: var(--text-secondary);
margin-left: 6px;
}
.cardBalCrypto {
font-size: 15px;
font-weight: 500;
font-family: var(--font-mono);
color: var(--text-primary);
}
.cardBot {
display: flex;
align-items: center;
justify-content: space-between;
}
.cardPrice {
font-size: 13px;
color: var(--text-secondary);
font-family: var(--font-mono);
}
.cardBotRight {
display: flex;
align-items: center;
gap: 8px;
}
.cardBalUsd {
font-size: 13px;
color: var(--text-secondary);
font-family: var(--font-mono);
}
@media (max-width: 900px) {
.table {
font-size: 13px;
}
.table td,
.table thead th {
padding: 10px 12px;
}
.table td {
white-space: nowrap;
}
}
.mobileActions {
display: none;
}
@media (max-width: 640px) {
.mobileActions {
display: flex;
justify-content: center;
padding: 16px 0;
}
.table {
display: none;
}
.mobileList {
display: block;
}
.change {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
}
.sendBtn {
width: 100%;
font-size: 14px;
}
}

View File

@@ -0,0 +1,130 @@
import { useState } from 'react'
import { TOKENS } from '../model/tokens'
import styles from './TokenTable.module.css'
export function TokenTable() {
const [favs, setFavs] = useState<boolean[]>(TOKENS.map((t) => t.fav))
function toggleFav(i: number) {
setFavs((prev) => prev.map((v, idx) => (idx === i ? !v : v)))
}
const sendIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#4A6DFF" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M7 17L17 7M17 7H7M17 7v10" />
</svg>
)
return (
<>
<div className={styles.wrap}>
{/* Desktop table */}
<table className={styles.table}>
<thead>
<tr>
<th className={styles.thStar}></th>
<th>Токены</th>
<th className={styles.right}>Цена</th>
<th className={styles.right}>Изменение</th>
<th className={styles.right}>Баланс</th>
<th className={styles.center}></th>
</tr>
</thead>
<tbody>
{TOKENS.map((t, i) => (
<tr key={t.ticker}>
<td>
<button
className={`${styles.star} ${favs[i] ? styles.starOn : ''}`}
onClick={() => toggleFav(i)}
type="button"
aria-label={favs[i] ? 'Убрать из избранного' : 'В избранное'}
>
</button>
</td>
<td>
<div className={styles.tokId}>
<div className={styles.tokLogo} style={{ background: t.color }}>
{t.logo && <img src={t.logo} alt={t.ticker} className={t.ticker === 'ARB' ? styles.arb : ''} />}
</div>
<div className={styles.balCol}>
<b className={styles.cardTicker}>{t.ticker}</b>
<span className={styles.noFont}>{t.name}</span>
</div>
</div>
</td>
<td className={styles.right}>
<span className={styles.price}>{t.price}</span>
</td>
<td className={styles.right}>
<span className={`${styles.change} ${t.change >= 0 ? styles.up : styles.dn}`}>
{t.change >= 0 ? '↑' : '↓'} {t.change >= 0 ? '+' : ''}{t.change}%
</span>
</td>
<td className={styles.right}>
<div className={styles.balCol}>
<b>{t.bal}</b>
<span>{t.usd}</span>
</div>
</td>
<td className={styles.center}>
<button className={styles.sendBtn} type="button" onClick={(e) => e.stopPropagation()}>
{sendIcon}
Отправить
</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Mobile card list */}
<div className={styles.mobileList}>
{TOKENS.map((t, i) => (
<div key={t.ticker} className={styles.card}>
<button
className={`${styles.star} ${favs[i] ? styles.starOn : ''}`}
onClick={() => toggleFav(i)}
type="button"
aria-label={favs[i] ? 'Убрать из избранного' : 'В избранное'}
>
</button>
<div className={styles.tokLogo} style={{ background: t.color }}>
{t.logo
? <img src={t.logo} alt={t.ticker} className={t.ticker === 'ARB' ? styles.arb : ''} />
: t.ticker[0]}
</div>
<div className={styles.cardInfo}>
<div className={styles.cardTop}>
<div>
<span className={styles.cardTicker}>{t.ticker}</span>
<span className={styles.cardName}>{t.name}</span>
</div>
<span className={styles.cardBalCrypto}>{t.bal}</span>
</div>
<div className={styles.cardBot}>
<span className={styles.cardPrice}>{t.price}</span>
<div className={styles.cardBotRight}>
<span className={styles.cardBalUsd}>{t.usd}</span>
<span className={`${styles.change} ${t.change >= 0 ? styles.up : styles.dn}`}>
{t.change >= 0 ? '↑' : '↓'} {Math.abs(t.change)}%
</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
<div className={styles.mobileActions}>
<button className={styles.sendBtn} type="button">
{sendIcon}
Отправить
</button>
</div>
</>
)
}

View File

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

View File

@@ -0,0 +1,76 @@
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32px;
height: 60px;
border-bottom: 1px solid var(--glass-border);
flex-shrink: 0;
}
.logo img {
height: 32px;
display: block;
}
.ticker {
display: flex;
gap: 24px;
font-size: 13px;
font-family: var(--font-mono);
}
.tick {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
}
.tick b {
color: var(--text-primary);
}
.up {
color: var(--success);
}
.dn {
color: #ff4d4d;
}
.account {
display: flex;
align-items: center;
gap: 10px;
}
.avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: linear-gradient(135deg, #3d2a8e, #5b3db8);
flex-shrink: 0;
}
.account span {
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
}
@media (max-width: 900px) {
.nav {
padding: 0 16px;
}
.ticker {
gap: 16px;
}
}
@media (max-width: 768px) {
.ticker {
display: none;
}
}

View File

@@ -0,0 +1,35 @@
import logo from '@shared/assets/logo-full-white.png'
import styles from './WalletHeader.module.css'
import { Link } from 'react-router-dom'
import { ROUTES } from '@shared/config/routes'
const TICKERS = [
{ symbol: 'BTC', price: '$66,916.00', change: 0.12, },
{ symbol: 'ETH', price: '$2,053.97', change: -0.12 },
{ symbol: 'SOL', price: '$163.84', change: -1.57 },
]
export function WalletHeader() {
return (
<nav className={styles.nav}>
<a href="/" className={styles.logo}>
<img src={logo} alt="ЭКСА" />
</a>
<div className={styles.ticker}>
{TICKERS.map(({ symbol, price, change }) => (
<div key={symbol} className={styles.tick}>
<b>{symbol}</b>
<span>{price}</span>
<span className={change >= 0 ? styles.up : styles.dn}>
{change >= 0 ? '+' : ''}{change}%
</span>
</div>
))}
</div>
<Link to={ROUTES.PROFILE} className={styles.account}>
<div className={styles.avatar} />
<span>Test account</span>
</Link>
</nav>
)
}