add project
This commit is contained in:
327
apps/web/src/app/bridge/page.tsx
Normal file
327
apps/web/src/app/bridge/page.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
BRIDGE_CHAINS,
|
||||
BRIDGE_CHAIN_OPTIONS,
|
||||
getDestinationChainOptions,
|
||||
getTokenOptions,
|
||||
getDefaultToken,
|
||||
type BridgeChainKey,
|
||||
} from '@/lib/bridge/constants';
|
||||
import { useBridge } from '@/hooks/useBridge';
|
||||
import { useGasPrice } from '@/hooks/useGasPrice';
|
||||
import { useGasSettings, type GasMode } from '@/hooks/useGasSettings';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
|
||||
const GAS_MODE_LABELS: Record<GasMode, string> = {
|
||||
slow: 'Slow',
|
||||
normal: 'Normal',
|
||||
fast: 'Fast',
|
||||
custom: 'Custom',
|
||||
};
|
||||
|
||||
const GAS_MODES: GasMode[] = ['slow', 'normal', 'fast', 'custom'];
|
||||
|
||||
export default function BridgePage() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const { data: gasPriceData, loading: gasLoading } = useGasPrice();
|
||||
const gas = useGasSettings(gasPriceData);
|
||||
const {
|
||||
status, quote, bridgeStatus, requestId, txHashes, error,
|
||||
sourceChain, setSourceChain, sourceWallet,
|
||||
fetchQuote, submitBridge, resetBridge,
|
||||
} = useBridge();
|
||||
|
||||
const [sourceToken, setSourceToken] = useState(() => getDefaultToken('ETH'));
|
||||
const [destChain, setDestChain] = useState<BridgeChainKey>('SOL');
|
||||
const [destToken, setDestToken] = useState(() => getDefaultToken('SOL'));
|
||||
const [amount, setAmount] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
|
||||
|
||||
const destChainOptions = useMemo(() => getDestinationChainOptions(sourceChain), [sourceChain]);
|
||||
const sourceTokenOptions = useMemo(() => getTokenOptions(sourceChain), [sourceChain]);
|
||||
const destTokenOptions = useMemo(() => getTokenOptions(destChain), [destChain]);
|
||||
|
||||
const handleSourceChainChange = (newChain: BridgeChainKey) => {
|
||||
setSourceChain(newChain);
|
||||
setSourceToken(getDefaultToken(newChain));
|
||||
// If dest chain is same as new source, switch dest
|
||||
const newDestOptions = getDestinationChainOptions(newChain);
|
||||
if (!newDestOptions.includes(destChain)) {
|
||||
setDestChain(newDestOptions[0]);
|
||||
setDestToken(getDefaultToken(newDestOptions[0]));
|
||||
}
|
||||
handleReset();
|
||||
};
|
||||
|
||||
const handleDestChainChange = (newChain: BridgeChainKey) => {
|
||||
setDestChain(newChain);
|
||||
setDestToken(getDefaultToken(newChain));
|
||||
handleReset();
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const canQuote =
|
||||
Number(amount) > 0 &&
|
||||
status !== 'quoting' &&
|
||||
status !== 'executing' &&
|
||||
status !== 'monitoring';
|
||||
const canBridge = !!quote && confirmed && status !== 'executing' && status !== 'monitoring';
|
||||
const isEvmSource = sourceChain === 'ETH' || sourceChain === 'BSC';
|
||||
const showGasControls = sourceChain === 'ETH'; // BSC uses fixed gas price
|
||||
|
||||
const handleQuote = async () => {
|
||||
setConfirmed(false);
|
||||
await fetchQuote({ sourceChain, sourceToken, destChain, destToken, amount });
|
||||
};
|
||||
|
||||
const handleBridge = async () => {
|
||||
await submitBridge(
|
||||
{ sourceChain, sourceToken, destChain, destToken, amount },
|
||||
isEvmSource ? gas.effectiveMaxFee : null,
|
||||
isEvmSource ? gas.effectivePriorityFee : null,
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setConfirmed(false);
|
||||
resetBridge();
|
||||
};
|
||||
|
||||
const tierGwei = (mode: GasMode): string => {
|
||||
if (mode === 'custom') return '';
|
||||
if (!gasPriceData) return '...';
|
||||
const v = gasPriceData[mode].maxFeePerGas;
|
||||
if (v >= 1) return v.toFixed(2);
|
||||
const s = v.toFixed(4);
|
||||
return s.replace(/0+$/, '').replace(/\.$/, '');
|
||||
};
|
||||
|
||||
const sourceExplorerBase = BRIDGE_CHAINS[sourceChain].explorerTxBaseUrl;
|
||||
const destExplorerBase = BRIDGE_CHAINS[destChain].explorerTxBaseUrl;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720, margin: '50px auto', padding: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<h1>Bridge</h1>
|
||||
<Link href="/dashboard" style={navButtonStyle}>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid #ccc', padding: 16 }}>
|
||||
{/* Source Chain */}
|
||||
<div style={fieldGroupStyle}>
|
||||
<label>Source Chain</label>
|
||||
<select
|
||||
value={sourceChain}
|
||||
onChange={(e) => handleSourceChainChange(e.target.value as BridgeChainKey)}
|
||||
style={inputStyle}
|
||||
>
|
||||
{BRIDGE_CHAIN_OPTIONS.map((key) => (
|
||||
<option key={key} value={key}>{BRIDGE_CHAINS[key].label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Source Token */}
|
||||
<div style={fieldGroupStyle}>
|
||||
<label>Source Token</label>
|
||||
<select
|
||||
value={sourceToken}
|
||||
onChange={(e) => { setSourceToken(e.target.value); handleReset(); }}
|
||||
style={inputStyle}
|
||||
>
|
||||
{sourceTokenOptions.map((sym) => (
|
||||
<option key={sym} value={sym}>{sym}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Destination Chain */}
|
||||
<div style={fieldGroupStyle}>
|
||||
<label>Destination Chain</label>
|
||||
<select
|
||||
value={destChain}
|
||||
onChange={(e) => handleDestChainChange(e.target.value as BridgeChainKey)}
|
||||
style={inputStyle}
|
||||
>
|
||||
{destChainOptions.map((key) => (
|
||||
<option key={key} value={key}>{BRIDGE_CHAINS[key].label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Destination Token */}
|
||||
<div style={fieldGroupStyle}>
|
||||
<label>Destination Token</label>
|
||||
<select
|
||||
value={destToken}
|
||||
onChange={(e) => { setDestToken(e.target.value); handleReset(); }}
|
||||
style={inputStyle}
|
||||
>
|
||||
{destTokenOptions.map((sym) => (
|
||||
<option key={sym} value={sym}>{sym}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div style={fieldGroupStyle}>
|
||||
<label>Amount</label>
|
||||
<input
|
||||
value={amount}
|
||||
onChange={(e) => { setAmount(e.target.value); handleReset(); }}
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gas Speed — only for ETH source */}
|
||||
{showGasControls ? (
|
||||
<div style={fieldGroupStyle}>
|
||||
<label>Gas Speed {gasLoading ? '(loading...)' : ''}</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{GAS_MODES.map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => gas.setGasMode(mode)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 4px',
|
||||
border: gas.gasMode === mode ? '2px solid #333' : '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
background: gas.gasMode === mode ? '#f0f0f0' : '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div>{GAS_MODE_LABELS[mode]}</div>
|
||||
{mode !== 'custom' && (
|
||||
<div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>
|
||||
{tierGwei(mode)} gwei
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{gas.gasMode === 'custom' && (
|
||||
<input
|
||||
value={gas.customGwei}
|
||||
onChange={(e) => gas.setCustomGwei(e.target.value)}
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="Enter gwei"
|
||||
style={{ ...inputStyle, marginTop: 6 }}
|
||||
/>
|
||||
)}
|
||||
<p style={{ marginTop: 4, fontSize: 13, color: '#666' }}>
|
||||
Effective: {gas.displayGwei}
|
||||
</p>
|
||||
</div>
|
||||
) : sourceChain === 'BSC' ? (
|
||||
<div style={fieldGroupStyle}>
|
||||
<label>Fee</label>
|
||||
<p style={{ fontSize: 13, color: '#666' }}>Fixed: <strong>0.055 gwei</strong> (BSC)</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={fieldGroupStyle}>
|
||||
<label>Fee</label>
|
||||
<p style={{ fontSize: 13, color: '#666' }}>Auto (managed by Relay)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={() => void handleQuote()} disabled={!canQuote} style={{ padding: '8px 16px' }}>
|
||||
{status === 'quoting' ? 'Getting Quote...' : 'Get Quote'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quote Review */}
|
||||
{quote && (
|
||||
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20 }}>
|
||||
<h2 style={{ marginBottom: 12 }}>Review</h2>
|
||||
<p>Expected output: <strong>{quote.outputAmountFormatted} {quote.outputSymbol}</strong></p>
|
||||
<p>Minimum output: <strong>{quote.minimumAmountFormatted}</strong></p>
|
||||
<p>Estimated fee: <strong>{quote.feeSummary}</strong></p>
|
||||
<p>Estimated time: <strong>{quote.timeEstimateSeconds ? `${quote.timeEstimateSeconds}s` : 'Unavailable'}</strong></p>
|
||||
{showGasControls && (
|
||||
<p>Gas: <strong>{gas.displayGwei} ({GAS_MODE_LABELS[gas.gasMode]})</strong></p>
|
||||
)}
|
||||
{sourceChain === 'BSC' && (
|
||||
<p>Gas: <strong>0.055 gwei</strong> (BSC fixed)</p>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'flex', gap: 8, marginTop: 16, alignItems: 'flex-start' }}>
|
||||
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
|
||||
<span>I confirm the bridge amount, fee and destination shown above.</span>
|
||||
</label>
|
||||
|
||||
<button onClick={() => void handleBridge()} disabled={!canBridge} style={{ padding: '8px 16px', marginTop: 16 }}>
|
||||
{status === 'executing' ? 'Executing...' : status === 'monitoring' ? 'Monitoring...' : 'Bridge'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
{(requestId || txHashes.length > 0 || bridgeStatus) && (
|
||||
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20 }}>
|
||||
<h2 style={{ marginBottom: 12 }}>Status</h2>
|
||||
{requestId && <p>Request ID: <strong>{requestId}</strong></p>}
|
||||
{bridgeStatus && <p>Relay status: <strong>{bridgeStatus.status}</strong></p>}
|
||||
{txHashes.map((hash) => (
|
||||
<p key={hash}>
|
||||
Origin tx:{' '}
|
||||
<a href={`${sourceExplorerBase}${hash}`} target="_blank" rel="noreferrer">
|
||||
{hash}
|
||||
</a>
|
||||
</p>
|
||||
))}
|
||||
{(bridgeStatus?.txHashes ?? []).map((hash) => (
|
||||
<p key={hash}>
|
||||
Destination tx:{' '}
|
||||
<a href={`${destExplorerBase}${hash}`} target="_blank" rel="noreferrer">
|
||||
{hash}
|
||||
</a>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(error || status === 'error') && (
|
||||
<p style={{ color: 'red', marginTop: 16 }}>
|
||||
{error ?? 'Bridge failed'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldGroupStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
marginBottom: 12,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: 8,
|
||||
};
|
||||
|
||||
const navButtonStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '6px 12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
};
|
||||
152
apps/web/src/app/dashboard/page.tsx
Normal file
152
apps/web/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useBalances } from '@/hooks/useBalances';
|
||||
import type { ChainBalance } from '@/lib/balances/types';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user, wallets } = useAuthStore();
|
||||
const { portfolio, loading, refreshing, error, refresh } = useBalances();
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 820, margin: '50px auto', padding: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16 }}>
|
||||
<h1>Dashboard</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<span>{user?.email || 'Not authenticated'}</span>
|
||||
<Link href="/send" style={navButtonStyle}>
|
||||
Send
|
||||
</Link>
|
||||
<Link href="/receive" style={navButtonStyle}>
|
||||
Receive
|
||||
</Link>
|
||||
<Link href="/swap" style={navButtonStyle}>
|
||||
Swap
|
||||
</Link>
|
||||
<Link href="/bridge" style={navButtonStyle}>
|
||||
Bridge
|
||||
</Link>
|
||||
<Link href="/settings" style={navButtonStyle}>
|
||||
Settings
|
||||
</Link>
|
||||
<button onClick={() => void refresh()} style={navButtonStyle} disabled={loading || refreshing}>
|
||||
{refreshing ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20, marginBottom: 20 }}>
|
||||
<p style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>
|
||||
Total Portfolio USD
|
||||
</p>
|
||||
<h2 style={{ marginBottom: 8 }}>{formatUsd(portfolio?.totalUsd ?? null)}</h2>
|
||||
<p style={{ color: '#666', fontSize: 14 }}>
|
||||
{portfolio?.updatedAt
|
||||
? `Updated ${new Date(portfolio.updatedAt).toLocaleTimeString()}`
|
||||
: loading
|
||||
? 'Loading balances...'
|
||||
: 'Balances will appear after the first refresh.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ color: 'red', marginBottom: 12 }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{portfolio?.priceError && (
|
||||
<p style={{ color: '#b45309', marginBottom: 12 }}>
|
||||
USD pricing is partially unavailable: {portfolio.priceError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h2>Your Wallets</h2>
|
||||
{wallets.map((w) => {
|
||||
const chainBalance = getChainBalance(w.chain, portfolio?.chains);
|
||||
|
||||
return (
|
||||
<div key={w.chain} style={{ border: '1px solid #ccc', padding: 16, marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'center' }}>
|
||||
<h3>{w.chain}</h3>
|
||||
<span style={{ fontWeight: 600 }}>{formatUsd(chainBalance?.totalUsd ?? null)}</span>
|
||||
</div>
|
||||
<p style={{ wordBreak: 'break-all' }}>
|
||||
<strong>Address:</strong> {w.address}
|
||||
</p>
|
||||
|
||||
{chainBalance?.error && chainBalance.error !== '__transient__' && (
|
||||
<p style={{ color: 'red', marginTop: 8 }}>
|
||||
{chainBalance.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{chainBalance?.tokens.length ? (
|
||||
chainBalance.tokens.map((token) => (
|
||||
<div
|
||||
key={`${w.chain}-${token.symbol}`}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: 12,
|
||||
padding: '8px 0',
|
||||
borderTop: '1px solid #eee',
|
||||
}}
|
||||
>
|
||||
<span>{token.symbol}</span>
|
||||
<span>{formatTokenAmount(token.balanceFormatted)}</span>
|
||||
<span style={{ textAlign: 'right' }}>{formatUsd(token.valueUsd)}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p style={{ color: '#666', marginTop: 8 }}>
|
||||
{loading ? 'Loading balances...' : 'No balances loaded yet.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getChainBalance(chain: string, chains?: ChainBalance[]): ChainBalance | undefined {
|
||||
return chains?.find((item) => item.chain === chain);
|
||||
}
|
||||
|
||||
function formatUsd(value: number | null): string {
|
||||
if (typeof value !== 'number') {
|
||||
return 'Unavailable';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatTokenAmount(value: string): string {
|
||||
const numericValue = Number(value);
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 6,
|
||||
}).format(numericValue);
|
||||
}
|
||||
|
||||
const navButtonStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '6px 12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
};
|
||||
BIN
apps/web/src/app/favicon.ico
Normal file
BIN
apps/web/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
42
apps/web/src/app/globals.css
Normal file
42
apps/web/src/app/globals.css
Normal file
@@ -0,0 +1,42 @@
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
17
apps/web/src/app/layout.tsx
Normal file
17
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Crypto Wallet",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
141
apps/web/src/app/page.module.css
Normal file
141
apps/web/src/app/page.module.css
Normal file
@@ -0,0 +1,141 @@
|
||||
.page {
|
||||
--background: #fafafa;
|
||||
--foreground: #fff;
|
||||
|
||||
--text-primary: #000;
|
||||
--text-secondary: #666;
|
||||
|
||||
--button-primary-hover: #383838;
|
||||
--button-secondary-hover: #f2f2f2;
|
||||
--button-secondary-border: #ebebeb;
|
||||
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-geist-sans);
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
background-color: var(--foreground);
|
||||
padding: 120px 60px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.intro h1 {
|
||||
max-width: 320px;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
line-height: 48px;
|
||||
letter-spacing: -2.4px;
|
||||
text-wrap: balance;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.intro p {
|
||||
max-width: 440px;
|
||||
font-size: 18px;
|
||||
line-height: 32px;
|
||||
text-wrap: balance;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.intro a {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ctas {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
gap: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ctas a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border-radius: 128px;
|
||||
border: 1px solid transparent;
|
||||
transition: 0.2s;
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
a.primary {
|
||||
background: var(--text-primary);
|
||||
color: var(--background);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
a.secondary {
|
||||
border-color: var(--button-secondary-border);
|
||||
}
|
||||
|
||||
/* Enable hover only on non-touch devices */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
a.primary:hover {
|
||||
background: var(--button-primary-hover);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
a.secondary:hover {
|
||||
background: var(--button-secondary-hover);
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.main {
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.intro h1 {
|
||||
font-size: 32px;
|
||||
line-height: 40px;
|
||||
letter-spacing: -1.92px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.logo {
|
||||
filter: invert();
|
||||
}
|
||||
|
||||
.page {
|
||||
--background: #000;
|
||||
--foreground: #000;
|
||||
|
||||
--text-primary: #ededed;
|
||||
--text-secondary: #999;
|
||||
|
||||
--button-primary-hover: #ccc;
|
||||
--button-secondary-hover: #1a1a1a;
|
||||
--button-secondary-border: #1a1a1a;
|
||||
}
|
||||
}
|
||||
5
apps/web/src/app/page.tsx
Normal file
5
apps/web/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
225
apps/web/src/app/receive/page.tsx
Normal file
225
apps/web/src/app/receive/page.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import {
|
||||
SEND_CHAIN_OPTIONS,
|
||||
SEND_CHAINS,
|
||||
getTokenOptions,
|
||||
getDefaultToken,
|
||||
type SendChain,
|
||||
} from '@/lib/send/constants';
|
||||
import { generateReceiveUri } from '@/lib/qr/generate';
|
||||
|
||||
export default function ReceivePage() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const wallets = useAuthStore((state) => state.wallets);
|
||||
|
||||
const [chain, setChain] = useState<SendChain>('ETH');
|
||||
const [token, setToken] = useState<string>(getDefaultToken('ETH'));
|
||||
const [amount, setAmount] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
|
||||
// Reset token when chain changes
|
||||
useEffect(() => {
|
||||
setToken(getDefaultToken(chain));
|
||||
setAmount('');
|
||||
}, [chain]);
|
||||
|
||||
const wallet = useMemo(
|
||||
() => wallets.find((w) => w.chain === SEND_CHAINS[chain].walletChain),
|
||||
[wallets, chain],
|
||||
);
|
||||
|
||||
const address = wallet?.address ?? '';
|
||||
|
||||
// Ensure token is valid for the current chain (guards against stale state during chain switch)
|
||||
const effectiveToken = useMemo(() => {
|
||||
const options = getTokenOptions(chain);
|
||||
return options.includes(token) ? token : getDefaultToken(chain);
|
||||
}, [chain, token]);
|
||||
|
||||
const qrUri = useMemo(() => {
|
||||
if (!address) return '';
|
||||
return generateReceiveUri({
|
||||
chain,
|
||||
token: effectiveToken,
|
||||
address,
|
||||
amount: amount.trim() || undefined,
|
||||
});
|
||||
}, [chain, effectiveToken, address, amount]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!address) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = address;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const tokenOptions = getTokenOptions(chain);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 520, margin: '50px auto', padding: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<h1>Receive</h1>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Link href="/send" style={navButtonStyle}>Send</Link>
|
||||
<Link href="/dashboard" style={navButtonStyle}>Dashboard</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chain selector */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={labelStyle}>Network</label>
|
||||
<select
|
||||
value={chain}
|
||||
onChange={(e) => setChain(e.target.value as SendChain)}
|
||||
style={selectStyle}
|
||||
>
|
||||
{SEND_CHAIN_OPTIONS.map((c) => (
|
||||
<option key={c} value={c}>{SEND_CHAINS[c].label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Token selector */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={labelStyle}>Token</label>
|
||||
<select
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
{tokenOptions.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Amount (optional) */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<label style={labelStyle}>Amount (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (/^\d*\.?\d*$/.test(v)) setAmount(v);
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* QR Code */}
|
||||
{address && (
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
padding: 16,
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #ddd',
|
||||
}}>
|
||||
<QRCodeSVG value={qrUri} size={256} level="M" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Address display */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={labelStyle}>Your {SEND_CHAINS[chain].label} Address</label>
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
wordBreak: 'break-all',
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
background: '#f9f9f9',
|
||||
}}>
|
||||
{address || 'No wallet found for this chain'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copy button */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={!address}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 16px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
cursor: address ? 'pointer' : 'not-allowed',
|
||||
background: copied ? '#d4edda' : '#fff',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy Address'}
|
||||
</button>
|
||||
|
||||
{/* URI preview */}
|
||||
{qrUri && (
|
||||
<div style={{ marginTop: 16, fontSize: 11, color: '#888', wordBreak: 'break-all' }}>
|
||||
<strong>QR URI:</strong> {qrUri}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
marginBottom: 4,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const navButtonStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '6px 12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
};
|
||||
561
apps/web/src/app/send/page.tsx
Normal file
561
apps/web/src/app/send/page.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Scanner } from '@yudiel/react-qr-scanner';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { useBalances } from '@/hooks/useBalances';
|
||||
import { useGasPrice } from '@/hooks/useGasPrice';
|
||||
import { useGasSettings, type GasMode } from '@/hooks/useGasSettings';
|
||||
import {
|
||||
SEND_CHAIN_OPTIONS,
|
||||
SEND_CHAINS,
|
||||
getTokenOptions,
|
||||
getDefaultToken,
|
||||
type SendChain,
|
||||
} from '@/lib/send/constants';
|
||||
import { validateAddress } from '@/lib/send/validate';
|
||||
import { parseQrUri } from '@/lib/qr/parse';
|
||||
import { executeSend, type SendResult } from '@/lib/send/execute';
|
||||
import type { ChainBalance } from '@/lib/balances/types';
|
||||
|
||||
const GAS_MODE_LABELS: Record<GasMode, string> = {
|
||||
slow: 'Slow',
|
||||
normal: 'Normal',
|
||||
fast: 'Fast',
|
||||
custom: 'Custom',
|
||||
};
|
||||
const GAS_MODES: GasMode[] = ['slow', 'normal', 'fast', 'custom'];
|
||||
|
||||
type SendStatus = 'idle' | 'review' | 'sending' | 'success' | 'error';
|
||||
|
||||
export default function SendPage() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const wallets = useAuthStore((state) => state.wallets);
|
||||
const { portfolio } = useBalances();
|
||||
const { data: gasPriceData } = useGasPrice();
|
||||
const gas = useGasSettings(gasPriceData);
|
||||
|
||||
const [chain, setChain] = useState<SendChain>('ETH');
|
||||
const [token, setToken] = useState<string>(getDefaultToken('ETH'));
|
||||
const [recipient, setRecipient] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [status, setStatus] = useState<SendStatus>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<SendResult | null>(null);
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
|
||||
|
||||
// Reset token on chain change
|
||||
useEffect(() => {
|
||||
setToken(getDefaultToken(chain));
|
||||
setRecipient('');
|
||||
setAmount('');
|
||||
setConfirmed(false);
|
||||
setStatus('idle');
|
||||
setError(null);
|
||||
setResult(null);
|
||||
}, [chain]);
|
||||
|
||||
const wallet = useMemo(
|
||||
() => wallets.find((w) => w.chain === SEND_CHAINS[chain].walletChain),
|
||||
[wallets, chain],
|
||||
);
|
||||
|
||||
const fromAddress = wallet?.address ?? '';
|
||||
|
||||
// Get available balance for the selected token
|
||||
const availableBalance = useMemo(() => {
|
||||
if (!portfolio?.chains) return null;
|
||||
const chainBalance: ChainBalance | undefined = portfolio.chains.find(
|
||||
(c) => c.chain === SEND_CHAINS[chain].walletChain,
|
||||
);
|
||||
if (!chainBalance) return null;
|
||||
const tokenBalance = chainBalance.tokens.find((t) => t.symbol === token);
|
||||
return tokenBalance?.balanceFormatted ?? null;
|
||||
}, [portfolio, chain, token]);
|
||||
|
||||
// Validate address
|
||||
const addressValidation = useMemo(() => {
|
||||
if (!recipient.trim()) return null;
|
||||
return validateAddress(chain, recipient);
|
||||
}, [chain, recipient]);
|
||||
|
||||
// Handle QR scan
|
||||
const handleScan = useCallback((results: Array<{ rawValue: string }>) => {
|
||||
if (!results.length) return;
|
||||
const raw = results[0].rawValue;
|
||||
if (!raw) return;
|
||||
|
||||
const parsed = parseQrUri(raw);
|
||||
setScannerOpen(false);
|
||||
|
||||
if (parsed.chain) {
|
||||
setChain(parsed.chain);
|
||||
// Wait for chain useEffect, then set token/recipient/amount
|
||||
setTimeout(() => {
|
||||
if (parsed.token) setToken(parsed.token);
|
||||
if (parsed.address) setRecipient(parsed.address);
|
||||
if (parsed.amount) setAmount(parsed.amount);
|
||||
}, 50);
|
||||
} else if (parsed.address) {
|
||||
setRecipient(parsed.address);
|
||||
if (parsed.amount) setAmount(parsed.amount);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleReview = () => {
|
||||
setError(null);
|
||||
|
||||
if (!recipient.trim()) {
|
||||
setError('Recipient address is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateAddress(chain, recipient);
|
||||
if (!validation.valid) {
|
||||
setError(validation.error || 'Invalid address');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!amount || Number(amount) <= 0) {
|
||||
setError('Enter a valid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('review');
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!wallet) {
|
||||
setError('Wallet not found for this chain');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('sending');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const sendResult = await executeSend({
|
||||
chain,
|
||||
token,
|
||||
toAddress: recipient.trim(),
|
||||
amount,
|
||||
privateKey: wallet.privateKey,
|
||||
fromAddress,
|
||||
maxFeeGwei: chain === 'ETH' ? gas.effectiveMaxFee : null,
|
||||
priorityFeeGwei: chain === 'ETH' ? gas.effectivePriorityFee : null,
|
||||
});
|
||||
|
||||
setResult(sendResult);
|
||||
setStatus('success');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Transaction failed');
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setRecipient('');
|
||||
setAmount('');
|
||||
setConfirmed(false);
|
||||
setStatus('idle');
|
||||
setError(null);
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
const handleMax = () => {
|
||||
if (availableBalance) {
|
||||
setAmount(availableBalance);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const tokenOptions = getTokenOptions(chain);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 520, margin: '50px auto', padding: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<h1>Send</h1>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Link href="/receive" style={navButtonStyle}>Receive</Link>
|
||||
<Link href="/dashboard" style={navButtonStyle}>Dashboard</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR Scanner Modal */}
|
||||
{scannerOpen && (
|
||||
<div style={overlayStyle}>
|
||||
<div style={modalStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<h3 style={{ margin: 0 }}>Scan QR Code</h3>
|
||||
<button onClick={() => setScannerOpen(false)} style={{ padding: '4px 8px', cursor: 'pointer' }}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||
<Scanner
|
||||
onScan={handleScan}
|
||||
onError={(err) => {
|
||||
console.error('QR scanner error:', err);
|
||||
setScannerOpen(false);
|
||||
}}
|
||||
formats={['qr_code']}
|
||||
styles={{ container: { width: '100%' } }}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: '#888', marginTop: 8, textAlign: 'center' }}>
|
||||
Point your camera at a QR code to auto-fill send details
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success state */}
|
||||
{status === 'success' && result && (
|
||||
<div style={{ border: '2px solid #28a745', borderRadius: 8, padding: 20, marginBottom: 20 }}>
|
||||
<h3 style={{ color: '#28a745', marginTop: 0 }}>Transaction Sent!</h3>
|
||||
<p style={{ wordBreak: 'break-all', fontSize: 13, fontFamily: 'monospace' }}>
|
||||
<strong>TX Hash:</strong> {result.hash}
|
||||
</p>
|
||||
<a
|
||||
href={result.explorerUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#007bff', fontSize: 13 }}
|
||||
>
|
||||
View on Explorer
|
||||
</a>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<button onClick={handleReset} style={primaryButtonStyle}>
|
||||
Send Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form (hidden during success) */}
|
||||
{status !== 'success' && (
|
||||
<>
|
||||
{/* Chain selector */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={labelStyle}>Network</label>
|
||||
<select
|
||||
value={chain}
|
||||
onChange={(e) => setChain(e.target.value as SendChain)}
|
||||
style={selectStyle}
|
||||
disabled={status === 'sending'}
|
||||
>
|
||||
{SEND_CHAIN_OPTIONS.map((c) => (
|
||||
<option key={c} value={c}>{SEND_CHAINS[c].label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Token selector */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={labelStyle}>Token</label>
|
||||
<select
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
style={selectStyle}
|
||||
disabled={status === 'sending'}
|
||||
>
|
||||
{tokenOptions.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
{availableBalance !== null && (
|
||||
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#666' }}>
|
||||
Available: {availableBalance} {token}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recipient address */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={labelStyle}>Recipient Address</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Enter ${SEND_CHAINS[chain].label} address`}
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
disabled={status === 'sending'}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setScannerOpen(true)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: 13,
|
||||
}}
|
||||
disabled={status === 'sending'}
|
||||
>
|
||||
Scan QR
|
||||
</button>
|
||||
</div>
|
||||
{addressValidation && !addressValidation.valid && (
|
||||
<p style={{ margin: '4px 0 0', fontSize: 12, color: 'red' }}>{addressValidation.error}</p>
|
||||
)}
|
||||
{addressValidation?.valid && (
|
||||
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#28a745' }}>Valid address</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={labelStyle}>Amount</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (/^\d*\.?\d*$/.test(v)) setAmount(v);
|
||||
}}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
disabled={status === 'sending'}
|
||||
/>
|
||||
{availableBalance !== null && (
|
||||
<button
|
||||
onClick={handleMax}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
}}
|
||||
disabled={status === 'sending'}
|
||||
>
|
||||
Max
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gas settings (ETH only) */}
|
||||
{chain === 'ETH' && (
|
||||
<div style={{ marginBottom: 16, padding: 12, border: '1px solid #eee', borderRadius: 4 }}>
|
||||
<label style={labelStyle}>Gas Speed</label>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
||||
{GAS_MODES.map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => gas.setGasMode(mode)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 4px',
|
||||
border: gas.gasMode === mode ? '2px solid #333' : '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
fontWeight: gas.gasMode === mode ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{GAS_MODE_LABELS[mode]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{gas.gasMode === 'custom' && (
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="Max fee in gwei"
|
||||
value={gas.customGwei}
|
||||
onChange={(e) => gas.setCustomGwei(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
)}
|
||||
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#666' }}>
|
||||
Estimated gas: {gas.displayGwei}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fee info for non-ETH chains */}
|
||||
{chain !== 'ETH' && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<p style={{ fontSize: 12, color: '#666' }}>
|
||||
{chain === 'SOL' && 'Fee: Auto (~0.000005 SOL)'}
|
||||
{chain === 'TRX' && 'Fee: Auto (Energy/Bandwidth)'}
|
||||
{chain === 'BTC' && 'Fee: Auto (market rate sat/vB)'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ marginBottom: 16, fontSize: 12, color: '#999' }}>
|
||||
Platform fee: 0.7% per transaction
|
||||
</p>
|
||||
|
||||
{/* Review section */}
|
||||
{status === 'review' && (
|
||||
<div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16, marginBottom: 16 }}>
|
||||
<h3 style={{ marginTop: 0 }}>Review Transaction</h3>
|
||||
<div style={reviewRowStyle}>
|
||||
<span style={{ color: '#666' }}>From:</span>
|
||||
<span style={{ wordBreak: 'break-all', fontSize: 12, fontFamily: 'monospace' }}>{fromAddress}</span>
|
||||
</div>
|
||||
<div style={reviewRowStyle}>
|
||||
<span style={{ color: '#666' }}>To:</span>
|
||||
<span style={{ wordBreak: 'break-all', fontSize: 12, fontFamily: 'monospace' }}>{recipient}</span>
|
||||
</div>
|
||||
<div style={reviewRowStyle}>
|
||||
<span style={{ color: '#666' }}>Amount:</span>
|
||||
<span style={{ fontWeight: 600 }}>{amount} {token}</span>
|
||||
</div>
|
||||
<div style={reviewRowStyle}>
|
||||
<span style={{ color: '#666' }}>Network:</span>
|
||||
<span>{SEND_CHAINS[chain].label}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
/>
|
||||
I confirm this transaction is correct. This action is irreversible.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<button
|
||||
onClick={() => { setStatus('idle'); setConfirmed(false); }}
|
||||
style={{ ...navButtonStyle, flex: 1 }}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!confirmed}
|
||||
style={{
|
||||
...primaryButtonStyle,
|
||||
flex: 1,
|
||||
opacity: confirmed ? 1 : 0.5,
|
||||
cursor: confirmed ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
Confirm & Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sending state */}
|
||||
{status === 'sending' && (
|
||||
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||
<p>Sending transaction...</p>
|
||||
<p style={{ fontSize: 12, color: '#666' }}>Please wait, do not close this page.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<p style={{ color: 'red', marginBottom: 12, fontSize: 13 }}>{error}</p>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{(status === 'idle' || status === 'error') && (
|
||||
<button onClick={handleReview} style={primaryButtonStyle}>
|
||||
Review Transaction
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Styles ───
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
marginBottom: 4,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const navButtonStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '6px 12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
background: '#fff',
|
||||
};
|
||||
|
||||
const primaryButtonStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 16px',
|
||||
border: '1px solid #333',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const reviewRowStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
padding: '6px 0',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
};
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1000,
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
maxWidth: 440,
|
||||
width: '90%',
|
||||
};
|
||||
45
apps/web/src/app/settings/page.tsx
Normal file
45
apps/web/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user } = useAuthStore();
|
||||
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 520, margin: '50px auto', padding: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<h1>Settings</h1>
|
||||
<Link href="/dashboard" style={navButtonStyle}>
|
||||
Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Account Section */}
|
||||
<div style={{ border: '1px solid #ccc', borderRadius: 4, padding: 16, marginBottom: 16 }}>
|
||||
<h3 style={{ marginTop: 0 }}>Account</h3>
|
||||
|
||||
<div>
|
||||
<p style={{ margin: 0, fontWeight: 600 }}>Email</p>
|
||||
<p style={{ margin: '4px 0 0', color: '#666', fontSize: 13 }}>{user?.email || 'Not authenticated'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Styles ──
|
||||
|
||||
const navButtonStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '6px 12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
background: '#fff',
|
||||
};
|
||||
328
apps/web/src/app/swap/page.tsx
Normal file
328
apps/web/src/app/swap/page.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
type SwapChain,
|
||||
SWAP_TOKEN_OPTIONS_BY_CHAIN,
|
||||
CHAIN_DEFAULT_TOKENS,
|
||||
getSlippageBpsForChain,
|
||||
getExplorerTxUrl,
|
||||
} from '@/lib/swap/constants';
|
||||
import { useSwap, type MultiChainSwapRequest } from '@/hooks/useSwap';
|
||||
import { useGasPrice } from '@/hooks/useGasPrice';
|
||||
import { useGasSettings, type GasMode } from '@/hooks/useGasSettings';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
|
||||
const GAS_MODE_LABELS: Record<GasMode, string> = {
|
||||
slow: 'Slow',
|
||||
normal: 'Normal',
|
||||
fast: 'Fast',
|
||||
custom: 'Custom',
|
||||
};
|
||||
|
||||
const GAS_MODES: GasMode[] = ['slow', 'normal', 'fast', 'custom'];
|
||||
const CHAINS: SwapChain[] = ['ETH', 'SOL', 'TRX', 'BSC'];
|
||||
|
||||
export default function SwapPage() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const { data: gasPriceData, loading: gasLoading } = useGasPrice();
|
||||
const gas = useGasSettings(gasPriceData);
|
||||
const {
|
||||
status,
|
||||
quote,
|
||||
error,
|
||||
txHash,
|
||||
approvalHashes,
|
||||
liveEstimate,
|
||||
chain,
|
||||
setChain,
|
||||
fetchQuote,
|
||||
submitSwap,
|
||||
resetSwap,
|
||||
estimateOutput,
|
||||
} = useSwap();
|
||||
|
||||
const tokenOptions = SWAP_TOKEN_OPTIONS_BY_CHAIN[chain];
|
||||
const defaults = CHAIN_DEFAULT_TOKENS[chain];
|
||||
const [fromSymbol, setFromSymbol] = useState(defaults.from);
|
||||
const [toSymbol, setToSymbol] = useState(defaults.to);
|
||||
const [amount, setAmount] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
|
||||
const slippageBps = useMemo(() => getSlippageBpsForChain(chain, fromSymbol, toSymbol), [chain, fromSymbol, toSymbol]);
|
||||
const slippagePercent = (slippageBps / 100).toFixed(2);
|
||||
|
||||
const request = useMemo<MultiChainSwapRequest>(
|
||||
() => ({
|
||||
chain,
|
||||
fromSymbol,
|
||||
toSymbol,
|
||||
amount,
|
||||
slippageBps,
|
||||
}),
|
||||
[chain, amount, fromSymbol, slippageBps, toSymbol]
|
||||
);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
estimateOutput(request);
|
||||
}, [estimateOutput, request]);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canQuote = fromSymbol !== toSymbol && Number(amount) > 0 && request.slippageBps > 0;
|
||||
const canSwap = !!quote && confirmed && status !== 'approving' && status !== 'swapping' && status !== 'quoting';
|
||||
|
||||
const handleChainChange = (newChain: SwapChain) => {
|
||||
setChain(newChain);
|
||||
const newDefaults = CHAIN_DEFAULT_TOKENS[newChain];
|
||||
setFromSymbol(newDefaults.from);
|
||||
setToSymbol(newDefaults.to);
|
||||
setAmount('');
|
||||
setConfirmed(false);
|
||||
resetSwap();
|
||||
};
|
||||
|
||||
const handleQuote = async () => {
|
||||
setConfirmed(false);
|
||||
await fetchQuote(request);
|
||||
};
|
||||
|
||||
const handleSwap = async () => {
|
||||
await submitSwap(request, gas.effectiveMaxFee, gas.effectivePriorityFee);
|
||||
};
|
||||
|
||||
const handleFieldReset = () => {
|
||||
setConfirmed(false);
|
||||
resetSwap();
|
||||
};
|
||||
|
||||
const tierGwei = (mode: GasMode): string => {
|
||||
if (mode === 'custom') return '';
|
||||
if (!gasPriceData) return '...';
|
||||
const v = gasPriceData[mode].maxFeePerGas;
|
||||
if (v >= 1) return v.toFixed(2);
|
||||
const s = v.toFixed(4);
|
||||
return s.replace(/0+$/, '').replace(/\.$/, '');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720, margin: '50px auto', padding: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<h1>Swap</h1>
|
||||
<Link href="/dashboard" style={navButtonStyle}>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid #ccc', padding: 16 }}>
|
||||
{/* Chain selector */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
{CHAINS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => handleChainChange(c)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 8px',
|
||||
border: chain === c ? '2px solid #333' : '1px solid #ccc',
|
||||
borderRadius: 6,
|
||||
background: chain === c ? '#f0f0f0' : '#fff',
|
||||
cursor: 'pointer',
|
||||
fontWeight: chain === c ? 700 : 400,
|
||||
fontSize: 15,
|
||||
}}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={fieldGroupStyle}>
|
||||
<label>From</label>
|
||||
<select value={fromSymbol} onChange={(event) => { setFromSymbol(event.target.value); handleFieldReset(); }} style={inputStyle}>
|
||||
{tokenOptions.map((symbol) => (
|
||||
<option key={symbol} value={symbol}>{symbol}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={fieldGroupStyle}>
|
||||
<label>To</label>
|
||||
<select value={toSymbol} onChange={(event) => { setToSymbol(event.target.value); handleFieldReset(); }} style={inputStyle}>
|
||||
{tokenOptions.map((symbol) => (
|
||||
<option key={symbol} value={symbol}>{symbol}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={fieldGroupStyle}>
|
||||
<label>Amount</label>
|
||||
<input value={amount} onChange={(event) => { setAmount(event.target.value); handleFieldReset(); }} type="number" min="0" step="any" style={inputStyle} />
|
||||
{liveEstimate && fromSymbol !== toSymbol && (
|
||||
<p style={{ marginTop: 4, fontSize: 14, color: '#666' }}>
|
||||
{liveEstimate.loading ? '...' : `~${liveEstimate.amountOut} ${toSymbol}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gas speed — only for ETH */}
|
||||
{chain === 'ETH' && (
|
||||
<div style={fieldGroupStyle}>
|
||||
<label>Gas Speed {gasLoading ? '(loading...)' : ''}</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{GAS_MODES.map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => gas.setGasMode(mode)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 4px',
|
||||
border: gas.gasMode === mode ? '2px solid #333' : '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
background: gas.gasMode === mode ? '#f0f0f0' : '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div>{GAS_MODE_LABELS[mode]}</div>
|
||||
{mode !== 'custom' && (
|
||||
<div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>
|
||||
{tierGwei(mode)} gwei
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{gas.gasMode === 'custom' && (
|
||||
<input
|
||||
value={gas.customGwei}
|
||||
onChange={(event) => gas.setCustomGwei(event.target.value)}
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="Enter gwei"
|
||||
style={{ ...inputStyle, marginTop: 6 }}
|
||||
/>
|
||||
)}
|
||||
<p style={{ marginTop: 4, fontSize: 13, color: '#666' }}>
|
||||
Effective: {gas.displayGwei}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fee info for non-ETH */}
|
||||
{chain === 'SOL' && (
|
||||
<p style={{ marginBottom: 12, fontSize: 14, color: '#666' }}>
|
||||
Fee: <strong>Auto</strong> (Jupiter Priority)
|
||||
</p>
|
||||
)}
|
||||
{chain === 'TRX' && (
|
||||
<p style={{ marginBottom: 12, fontSize: 14, color: '#666' }}>
|
||||
Fee: <strong>Auto</strong> (Energy/Bandwidth)
|
||||
</p>
|
||||
)}
|
||||
{chain === 'BSC' && (
|
||||
<p style={{ marginBottom: 12, fontSize: 14, color: '#666' }}>
|
||||
Fee: <strong>0.055 gwei</strong> (BSC fixed)
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p style={{ marginBottom: 12, fontSize: 14, color: '#666' }}>
|
||||
Slippage: <strong>{slippagePercent}%</strong> (auto)
|
||||
</p>
|
||||
<p style={{ marginBottom: 12, fontSize: 13, color: '#999' }}>
|
||||
Platform fee: 0.7% per swap
|
||||
</p>
|
||||
|
||||
{fromSymbol === toSymbol && (
|
||||
<p style={{ color: 'red', marginBottom: 12 }}>From and To tokens must be different.</p>
|
||||
)}
|
||||
|
||||
<button onClick={() => void handleQuote()} disabled={!canQuote || status === 'quoting'} style={{ padding: '8px 16px' }}>
|
||||
{status === 'quoting' ? 'Getting Quote...' : 'Get Quote'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{quote && (
|
||||
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20 }}>
|
||||
<h2 style={{ marginBottom: 12 }}>Review</h2>
|
||||
<p>Expected output: <strong>{quote.amountOutFormatted} {toSymbol}</strong></p>
|
||||
<p>Minimum output after slippage: <strong>{quote.minimumAmountOutFormatted} {toSymbol}</strong></p>
|
||||
{'executionPrice' in quote && <p>Execution price: <strong>{quote.executionPrice}</strong></p>}
|
||||
{'priceImpact' in quote && <p>Price impact: <strong>{quote.priceImpact}%</strong></p>}
|
||||
{'routeSymbols' in quote && <p>Route: <strong>{quote.routeSymbols.join(' -> ')}</strong></p>}
|
||||
{'routeFees' in quote && <p>Pool fees: <strong>{quote.routeFees.join(' / ')}</strong></p>}
|
||||
{'routeLabels' in quote && (quote as any).routeLabels?.length > 0 && (
|
||||
<p>Route: <strong>{(quote as any).routeLabels.join(' → ')}</strong></p>
|
||||
)}
|
||||
{chain === 'ETH' && <p>Gas: <strong>{gas.displayGwei} ({GAS_MODE_LABELS[gas.gasMode]})</strong></p>}
|
||||
<p>Slippage: <strong>{slippagePercent}%</strong></p>
|
||||
|
||||
<label style={{ display: 'flex', gap: 8, marginTop: 16, alignItems: 'flex-start' }}>
|
||||
<input type="checkbox" checked={confirmed} onChange={(event) => setConfirmed(event.target.checked)} />
|
||||
<span>I confirm the amount, route and slippage shown above.</span>
|
||||
</label>
|
||||
|
||||
<button onClick={() => void handleSwap()} disabled={!canSwap} style={{ padding: '8px 16px', marginTop: 16 }}>
|
||||
{status === 'approving' ? 'Approving...' : status === 'swapping' ? 'Swapping...' : 'Swap'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(approvalHashes.length > 0 || txHash) && (
|
||||
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20 }}>
|
||||
<h2 style={{ marginBottom: 12 }}>Transaction Status</h2>
|
||||
{approvalHashes.map((hash) => (
|
||||
<p key={hash}>
|
||||
Approval tx:{' '}
|
||||
<a href={getExplorerTxUrl(chain, hash)} target="_blank" rel="noreferrer">
|
||||
{hash.slice(0, 16)}...
|
||||
</a>
|
||||
</p>
|
||||
))}
|
||||
{txHash && (
|
||||
<p>
|
||||
Swap tx:{' '}
|
||||
<a href={getExplorerTxUrl(chain, txHash)} target="_blank" rel="noreferrer">
|
||||
{txHash.slice(0, 16)}...
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(error || status === 'error') && (
|
||||
<p style={{ color: 'red', marginTop: 16 }}>
|
||||
{error ?? 'Swap failed'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldGroupStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
marginBottom: 12,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: 8,
|
||||
};
|
||||
|
||||
const navButtonStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '6px 12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
};
|
||||
Reference in New Issue
Block a user