add project

This commit is contained in:
ZOMBIIIIIII
2026-04-08 14:11:27 +03:00
parent bfa95223a0
commit a81e29807c
115 changed files with 18413 additions and 0 deletions

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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

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

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

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/dashboard');
}

View 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',
};

View 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%',
};

View 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',
};

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