| import React, { useState } from 'react'; |
| import { Num, useToast } from '../components/ui/index'; |
|
|
| interface Props { balance: { sol: number; usdt: number }; onSent: () => void; } |
|
|
| export default function SendPage({ balance, onSent }: Props) { |
| const [token, setToken] = useState<'SOL' | 'USDT'>('SOL'); |
| const [to, setTo] = useState(''); |
| const [amount, setAmount] = useState(''); |
| const [loading, setLoading] = useState(false); |
| const [error, setError] = useState(''); |
| const [success, setSuccess] = useState<any>(null); |
| const [step, setStep] = useState<'form' | 'confirm' | 'done'>('form'); |
| const [risk, setRisk] = useState<any>(null); |
| const { addToast } = useToast(); |
|
|
| const max = token === 'SOL' ? balance.sol : balance.usdt; |
| const amt = parseFloat(amount) || 0; |
|
|
| const review = async () => { |
| setError(''); |
| if (!to.trim()) return setError('Recipient address required'); |
| if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(to.trim())) return setError('Invalid Solana address'); |
| if (!amt || amt <= 0) return setError('Enter a valid amount'); |
| if (amt > max) return setError(`Insufficient balance. Max: ${max.toFixed(token === 'SOL' ? 4 : 2)} ${token}`); |
| |
| if (window.solvox) { |
| try { |
| const r = await window.solvox.ai.assessRisk({ amount: amt, token, to: to.trim() }); |
| if (r.success) setRisk(r.risk); |
| } catch {} |
| } |
| setStep('confirm'); |
| }; |
|
|
| const send = async () => { |
| setLoading(true); setError(''); |
| try { |
| const r = window.solvox |
| ? await window.solvox.ai.executeConfirmed({ token, amount: amt, to: to.trim() }) |
| : { success: true, signature: 'dev_' + Date.now(), explorer: '#' }; |
| if (r.success) { |
| setSuccess(r); setStep('done'); onSent(); |
| addToast({ type: 'success', title: `${amount} ${token} sent` }); |
| } else { setError(r.error || 'Transaction failed'); setStep('form'); } |
| } catch (e: any) { setError(e.message); setStep('form'); } |
| setLoading(false); |
| }; |
|
|
| const reset = () => { setTo(''); setAmount(''); setError(''); setSuccess(null); setRisk(null); setStep('form'); }; |
|
|
| return ( |
| <div className="max-w-md mx-auto px-8 py-section"> |
| <h2 className="text-title-lg display-text text-ink mb-2">Send {token}</h2> |
| <p className="text-body-sm text-body mb-8">Balance: <span className="number-mono"><Num value={max} decimals={token === 'SOL' ? 4 : 2} /></span> {token}</p> |
| |
| {step === 'form' && ( |
| <div className="card space-y-5"> |
| {/* Token */} |
| <div className="flex gap-2 p-1 bg-surface-soft rounded-pill"> |
| {(['SOL', 'USDT'] as const).map(t => ( |
| <button key={t} onClick={() => setToken(t)} className={`flex-1 py-2 rounded-pill text-button transition-colors ${token === t ? 'bg-primary text-on-primary' : 'text-body hover:text-ink'}`}> |
| {t === 'SOL' ? '◎' : '₮'} {t} |
| </button> |
| ))} |
| </div> |
| {/* Recipient */} |
| <div> |
| <label className="text-caption-strong text-muted uppercase tracking-wider block mb-1.5">Recipient</label> |
| <input value={to} onChange={e => setTo(e.target.value)} placeholder="Solana address" className="input-field font-mono text-body-sm" /> |
| </div> |
| {/* Amount */} |
| <div> |
| <div className="flex justify-between items-center mb-1.5"> |
| <label className="text-caption-strong text-muted uppercase tracking-wider">Amount</label> |
| <button onClick={() => setAmount(max.toString())} className="btn-text text-body-sm py-0 px-1">Max</button> |
| </div> |
| <input type="number" value={amount} onChange={e => setAmount(e.target.value)} placeholder="0.00" step="any" className="input-field number-mono text-title-lg text-center" /> |
| <div className="flex gap-2 mt-2"> |
| {[25, 50, 75, 100].map(p => ( |
| <button key={p} onClick={() => setAmount((max * p / 100).toFixed(token === 'SOL' ? 4 : 2))} className="flex-1 py-1.5 rounded-pill text-caption-strong text-muted bg-surface-soft hover:bg-surface-strong transition-colors">{p}%</button> |
| ))} |
| </div> |
| </div> |
| {error && <div className="text-body-sm text-semantic-down bg-semantic-down/5 rounded-md p-3">{error}</div>} |
| <button onClick={review} className="btn-primary w-full">Review transaction</button> |
| </div> |
| )} |
| |
| {step === 'confirm' && ( |
| <div className="card space-y-5 page-enter"> |
| <div className="text-center mb-2"> |
| <div className="text-body text-muted">Confirm transaction</div> |
| <div className="display-text text-display-sm text-ink mt-1">{amount} {token}</div> |
| </div> |
| <div className="bg-surface-soft rounded-xl p-4 space-y-3 text-body-sm"> |
| <div className="flex justify-between"><span className="text-muted">To</span><span className="font-mono text-caption">{to.slice(0, 10)}…{to.slice(-6)}</span></div> |
| <div className="hairline-soft" /> |
| <div className="flex justify-between"><span className="text-muted">Fee</span><span className="number-mono text-caption">~0.000005 SOL</span></div> |
| <div className="flex justify-between"><span className="text-muted">Network</span><span className="badge-pill text-[10px]">DEVNET</span></div> |
| </div> |
| {/* AI Risk Assessment */} |
| {risk && ( |
| <div className={`rounded-xl p-4 text-body-sm ${risk.level === 'safe' ? 'bg-semantic-up/5' : risk.level === 'danger' ? 'bg-semantic-down/5' : 'bg-accent-yellow/5'}`}> |
| <div className="flex items-center gap-2 mb-1"> |
| <span className={`badge-pill text-[10px] ${risk.level === 'safe' ? 'badge-pill-green' : 'badge-pill-red'}`}>AI RISK: {risk.level.toUpperCase()}</span> |
| <span className="number-mono text-caption text-muted">{risk.score}/100</span> |
| </div> |
| {risk.factors?.length > 0 && <ul className="text-caption text-body mt-1 space-y-0.5">{risk.factors.map((f: string, i: number) => <li key={i}>· {f}</li>)}</ul>} |
| {risk.recommendation && <div className="text-caption text-muted mt-1">{risk.recommendation}</div>} |
| <div className="text-caption text-muted-soft mt-1 flex items-center gap-1"> |
| <div className="w-1 h-1 rounded-full bg-primary" /> |
| Analyzed by @qvac/llm-llamacpp + @qvac/embed-llamacpp |
| </div> |
| </div> |
| )} |
| {error && <div className="text-body-sm text-semantic-down">{error}</div>} |
| <div className="flex gap-3"> |
| <button onClick={() => setStep('form')} className="btn-secondary flex-1">Cancel</button> |
| <button onClick={send} disabled={loading} className="btn-primary flex-1 disabled:opacity-50">{loading ? 'Sending…' : `Send ${token}`}</button> |
| </div> |
| </div> |
| )} |
| |
| {step === 'done' && success && ( |
| <div className="card text-center space-y-5 page-enter"> |
| <div className="w-14 h-14 mx-auto rounded-full bg-semantic-up/10 flex items-center justify-center"> |
| <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#05b169" strokeWidth="2.5" strokeLinecap="round"><polyline points="20 6 9 17 4 12"/></svg> |
| </div> |
| <div className="display-text text-display-sm text-ink">Sent</div> |
| <p className="text-body text-muted">{amount} {token} sent successfully</p> |
| <div className="bg-surface-soft rounded-xl p-3 font-mono text-caption text-muted break-all">{success.signature}</div> |
| <a href={success.explorer} target="_blank" rel="noopener noreferrer" className="btn-text text-body-sm">View on Solscan →</a> |
| <button onClick={reset} className="btn-primary w-full">Send another</button> |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|