first commit
This commit is contained in:
1
src/widgets/about/index.ts
Normal file
1
src/widgets/about/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { About } from './ui/About'
|
||||
123
src/widgets/about/ui/About.module.css
Normal file
123
src/widgets/about/ui/About.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
51
src/widgets/about/ui/About.tsx
Normal file
51
src/widgets/about/ui/About.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/widgets/balance-card/index.ts
Normal file
1
src/widgets/balance-card/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { BalanceCard } from './ui/BalanceCard'
|
||||
102
src/widgets/balance-card/ui/BalanceCard.module.css
Normal file
102
src/widgets/balance-card/ui/BalanceCard.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
27
src/widgets/balance-card/ui/BalanceCard.tsx
Normal file
27
src/widgets/balance-card/ui/BalanceCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/widgets/currency-converter/index.ts
Normal file
1
src/widgets/currency-converter/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Converter } from './ui/Converter'
|
||||
27
src/widgets/currency-converter/model/tiers.ts
Normal file
27
src/widgets/currency-converter/model/tiers.ts
Normal 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
|
||||
}
|
||||
51
src/widgets/currency-converter/model/useConverter.ts
Normal file
51
src/widgets/currency-converter/model/useConverter.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
42
src/widgets/currency-converter/ui/AgreementCheckbox.tsx
Normal file
42
src/widgets/currency-converter/ui/AgreementCheckbox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
src/widgets/currency-converter/ui/CommissionTable.module.css
Normal file
82
src/widgets/currency-converter/ui/CommissionTable.module.css
Normal 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;
|
||||
}
|
||||
34
src/widgets/currency-converter/ui/CommissionTable.tsx
Normal file
34
src/widgets/currency-converter/ui/CommissionTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
240
src/widgets/currency-converter/ui/Converter.module.css
Normal file
240
src/widgets/currency-converter/ui/Converter.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
110
src/widgets/currency-converter/ui/Converter.tsx
Normal file
110
src/widgets/currency-converter/ui/Converter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
src/widgets/currency-converter/ui/Tiers.tsx
Normal file
27
src/widgets/currency-converter/ui/Tiers.tsx
Normal 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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
1
src/widgets/footer/index.ts
Normal file
1
src/widgets/footer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Footer } from './ui/Footer'
|
||||
134
src/widgets/footer/ui/Footer.module.css
Normal file
134
src/widgets/footer/ui/Footer.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
53
src/widgets/footer/ui/Footer.tsx
Normal file
53
src/widgets/footer/ui/Footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/widgets/header/index.ts
Normal file
1
src/widgets/header/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Header } from './ui/Header'
|
||||
73
src/widgets/header/ui/Header.module.css
Normal file
73
src/widgets/header/ui/Header.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
22
src/widgets/header/ui/Header.tsx
Normal file
22
src/widgets/header/ui/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/widgets/hero/index.ts
Normal file
1
src/widgets/hero/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Hero } from './ui/Hero'
|
||||
37
src/widgets/hero/lib/useCountdown.ts
Normal file
37
src/widgets/hero/lib/useCountdown.ts
Normal 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)),
|
||||
}
|
||||
}
|
||||
145
src/widgets/hero/ui/ConversionFlow.module.css
Normal file
145
src/widgets/hero/ui/ConversionFlow.module.css
Normal 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;
|
||||
}
|
||||
68
src/widgets/hero/ui/ConversionFlow.tsx
Normal file
68
src/widgets/hero/ui/ConversionFlow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
src/widgets/hero/ui/Countdown.module.css
Normal file
40
src/widgets/hero/ui/Countdown.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
31
src/widgets/hero/ui/Countdown.tsx
Normal file
31
src/widgets/hero/ui/Countdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
270
src/widgets/hero/ui/ExchangeCard.module.css
Normal file
270
src/widgets/hero/ui/ExchangeCard.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
84
src/widgets/hero/ui/ExchangeCard.tsx
Normal file
84
src/widgets/hero/ui/ExchangeCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
159
src/widgets/hero/ui/Hero.module.css
Normal file
159
src/widgets/hero/ui/Hero.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
43
src/widgets/hero/ui/Hero.tsx
Normal file
43
src/widgets/hero/ui/Hero.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/widgets/login-form/index.ts
Normal file
1
src/widgets/login-form/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { LoginForm } from './ui/LoginForm'
|
||||
86
src/widgets/login-form/ui/LoginForm.module.css
Normal file
86
src/widgets/login-form/ui/LoginForm.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
52
src/widgets/login-form/ui/LoginForm.tsx
Normal file
52
src/widgets/login-form/ui/LoginForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/widgets/networks-table/index.ts
Normal file
1
src/widgets/networks-table/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { NetworksTable } from './ui/NetworksTable'
|
||||
63
src/widgets/networks-table/model/networks.ts
Normal file
63
src/widgets/networks-table/model/networks.ts
Normal 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: '~2–15 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
|
||||
156
src/widgets/networks-table/ui/NetworksTable.module.css
Normal file
156
src/widgets/networks-table/ui/NetworksTable.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
54
src/widgets/networks-table/ui/NetworksTable.tsx
Normal file
54
src/widgets/networks-table/ui/NetworksTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2
src/widgets/profile/index.ts
Normal file
2
src/widgets/profile/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ProfileAvatar } from './ui/ProfileAvatar'
|
||||
export { ProfileSection } from './ui/ProfileSection'
|
||||
84
src/widgets/profile/ui/ProfileAvatar.module.css
Normal file
84
src/widgets/profile/ui/ProfileAvatar.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
25
src/widgets/profile/ui/ProfileAvatar.tsx
Normal file
25
src/widgets/profile/ui/ProfileAvatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/widgets/profile/ui/ProfileSection.module.css
Normal file
36
src/widgets/profile/ui/ProfileSection.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
17
src/widgets/profile/ui/ProfileSection.tsx
Normal file
17
src/widgets/profile/ui/ProfileSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/widgets/register-form/index.ts
Normal file
1
src/widgets/register-form/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { RegisterForm } from './ui/RegisterForm'
|
||||
64
src/widgets/register-form/model/useRegisterForm.ts
Normal file
64
src/widgets/register-form/model/useRegisterForm.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
101
src/widgets/register-form/ui/RegisterForm.module.css
Normal file
101
src/widgets/register-form/ui/RegisterForm.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
90
src/widgets/register-form/ui/RegisterForm.tsx
Normal file
90
src/widgets/register-form/ui/RegisterForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/widgets/seed-phrase/index.ts
Normal file
1
src/widgets/seed-phrase/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SeedPhraseWidget } from './ui/SeedPhraseWidget'
|
||||
52
src/widgets/seed-phrase/model/useSeedPhrase.ts
Normal file
52
src/widgets/seed-phrase/model/useSeedPhrase.ts
Normal 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 }
|
||||
}
|
||||
130
src/widgets/seed-phrase/ui/SeedPhraseWidget.module.css
Normal file
130
src/widgets/seed-phrase/ui/SeedPhraseWidget.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
56
src/widgets/seed-phrase/ui/SeedPhraseWidget.tsx
Normal file
56
src/widgets/seed-phrase/ui/SeedPhraseWidget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/widgets/swap-form/index.ts
Normal file
1
src/widgets/swap-form/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SwapForm } from './ui/SwapForm'
|
||||
68
src/widgets/swap-form/model/useSwapForm.ts
Normal file
68
src/widgets/swap-form/model/useSwapForm.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
24
src/widgets/swap-form/ui/RateRow.module.css
Normal file
24
src/widgets/swap-form/ui/RateRow.module.css
Normal 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;
|
||||
}
|
||||
25
src/widgets/swap-form/ui/RateRow.tsx
Normal file
25
src/widgets/swap-form/ui/RateRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
198
src/widgets/swap-form/ui/SwapCard.module.css
Normal file
198
src/widgets/swap-form/ui/SwapCard.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
101
src/widgets/swap-form/ui/SwapCard.tsx
Normal file
101
src/widgets/swap-form/ui/SwapCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
src/widgets/swap-form/ui/SwapDirectionButton.module.css
Normal file
38
src/widgets/swap-form/ui/SwapDirectionButton.module.css
Normal 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);
|
||||
}
|
||||
30
src/widgets/swap-form/ui/SwapDirectionButton.tsx
Normal file
30
src/widgets/swap-form/ui/SwapDirectionButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
src/widgets/swap-form/ui/SwapForm.module.css
Normal file
5
src/widgets/swap-form/ui/SwapForm.module.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.form {
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
58
src/widgets/swap-form/ui/SwapForm.tsx
Normal file
58
src/widgets/swap-form/ui/SwapForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
src/widgets/swap-form/ui/SwapInfoPanel.module.css
Normal file
42
src/widgets/swap-form/ui/SwapInfoPanel.module.css
Normal 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;
|
||||
}
|
||||
20
src/widgets/swap-form/ui/SwapInfoPanel.tsx
Normal file
20
src/widgets/swap-form/ui/SwapInfoPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
118
src/widgets/swap-form/ui/TokenSelect.module.css
Normal file
118
src/widgets/swap-form/ui/TokenSelect.module.css
Normal 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;
|
||||
}
|
||||
65
src/widgets/swap-form/ui/TokenSelect.tsx
Normal file
65
src/widgets/swap-form/ui/TokenSelect.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/widgets/token-table/index.ts
Normal file
1
src/widgets/token-table/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { TokenTable } from './ui/TokenTable'
|
||||
25
src/widgets/token-table/model/tokens.ts
Normal file
25
src/widgets/token-table/model/tokens.ts
Normal 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
|
||||
294
src/widgets/token-table/ui/TokenTable.module.css
Normal file
294
src/widgets/token-table/ui/TokenTable.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
130
src/widgets/token-table/ui/TokenTable.tsx
Normal file
130
src/widgets/token-table/ui/TokenTable.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
1
src/widgets/wallet-header/index.ts
Normal file
1
src/widgets/wallet-header/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { WalletHeader } from './ui/WalletHeader'
|
||||
76
src/widgets/wallet-header/ui/WalletHeader.module.css
Normal file
76
src/widgets/wallet-header/ui/WalletHeader.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
35
src/widgets/wallet-header/ui/WalletHeader.tsx
Normal file
35
src/widgets/wallet-header/ui/WalletHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user