SPB Git

Repository Public

Send.tsx 12.7 KB 149 lines tsx

1import { startAuthentication } from '@simplewebauthn/browser';2import { useQuery, useQueryClient } from '@tanstack/react-query';3import { useEffect, useMemo, useState } from 'react';4import { get, post } from '../api';5import type { Tab } from '../App';6import { useNetworks, usePortfolio, type Proposal } from '../hooks';7import { errText, useT, type Key } from '../i18n';8import { Addr, Btn, Card, Chips, Field, H, Icon, inputCls, Notice, short, StatePill, testNotSent, trimAmt } from '../ui';9import { AgentRecipientNotice } from './AgentFund';1011type Step = 'form' | 'review' | 'track';12const FLOW = ['pending_approval', 'signed', 'broadcast', 'confirming', 'finalized'];1314export function Send({ go, initial }: { go: (t: Tab) => void; initial?: { network?: string; asset?: string } }) {15  const t = useT(); const qc = useQueryClient();16  const nets = useNetworks(), pf = usePortfolio();17  const book = useQuery({ queryKey: ['book'], queryFn: () => get<{ id: string; network: string; address: string; label: string }[]>('/addressbook') });18  const [step, setStep] = useState<Step>('form');19  const [net, setNet] = useState(initial?.network ?? 'base'), [asset, setAsset] = useState(initial?.asset ?? ''), [to, setTo] = useState(''), [amount, setAmount] = useState(''), [note, setNote] = useState(''), [memo, setMemo] = useState('');20  const [p, setP] = useState<Proposal | null>(null), [busy, setBusy] = useState(''), [err, setErr] = useState(''), [checked, setChecked] = useState(false);21  const [idem, setIdem] = useState(() => crypto.randomUUID());22  const n = nets.data?.find((x) => x.id === net);23  const assets = useMemo(() => {24    if (!n) return [];25    const held = (id: string) => Number((pf.data?.balances.find((b) => b.network === n.id && b.asset === id)?.amount ?? '0') !== '0');26    return [n.native, ...n.tokens].sort((a, b) => held(b.id) - held(a.id) || Number(b.symbol === 'USDC') - Number(a.symbol === 'USDC'));27  }, [n, pf.data]);28  useEffect(() => { if (assets.length && !assets.some((a) => a.id === asset)) setAsset(assets[0]!.id); }, [assets, asset]);29  const bal = pf.data?.balances.find((b) => b.network === net && b.asset === asset);30  const a = assets.find((x) => x.id === asset);31  const fail = (e: unknown) => setErr(errText(t, e));3233  // live status while tracking34  const live = useQuery({ queryKey: ['proposal', p?.id], enabled: step === 'track' && !!p, refetchInterval: 4000, queryFn: () => get<Proposal>(`/transfers/proposals/${p!.id}`) });35  const cur = step === 'track' ? live.data ?? p : p;3637  const review = async () => {38    setBusy('review'); setErr('');39    try { setP(await post<Proposal>('/transfers/proposals', { network: net, asset, to: to.trim(), amount: amount.trim(), note, ...(n?.memo && memo.trim() ? { memo: memo.trim() } : {}) }, { 'idempotency-key': idem })); setChecked(false); setStep('review'); }40    catch (e) { fail(e); setIdem(crypto.randomUUID()); } finally { setBusy(''); }41  };42  const approve = async () => {43    if (!p) return;44    setBusy('prepare'); setErr('');45    try {46      const ra = await post<{ proposal: Proposal; options: any }>(`/transfers/proposals/${p.id}/request-approval`);47      setP(ra.proposal); setBusy('passkey');48      const assertion = await startAuthentication({ optionsJSON: ra.options }); // challenge = fingerprint of this exact transfer49      setBusy('submit');50      setP(await post<Proposal>(`/transfers/proposals/${p.id}/approve`, { assertion }));51      setStep('track'); void qc.invalidateQueries({ queryKey: ['portfolio'] });52    } catch (e) {53      fail(e);54      const fresh = await get<Proposal>(`/transfers/proposals/${p.id}`).catch(() => null);55      if (fresh) { setP(fresh); if (!['draft', 'pending_approval'].includes(fresh.state)) setStep('track'); }56    } finally { setBusy(''); }57  };58  const reset = () => { setStep('form'); setP(null); setTo(''); setAmount(''); setNote(''); setMemo(''); setErr(''); setIdem(crypto.randomUUID()); };59  const cancel = async () => { if (p) await post(`/transfers/proposals/${p.id}/reject`).catch(() => {}); reset(); };6061  if (step === 'form') return (62    <div className="space-y-4">63      <h1 className="pt-2 text-[30px] font-semibold tracking-[-0.02em]">{t('sendTitle')}</h1>64      {pf.data?.halted && <Notice tone="danger">{t('haltedBanner')}</Notice>}65      <Card className="space-y-4">66        <div><div className="mb-2 text-[13px] text-mute">{t('network')}</div>67          <Chips label={t('network')} value={net} onChange={(v) => { setNet(v); setTo(''); setMemo(''); }} options={(nets.data ?? []).map((x) => ({ id: x.id, label: x.name, net: x.id }))} /></div>68        <div><div className="mb-2 text-[13px] text-mute">{t('asset')}</div>69          <Chips label={t('asset')} value={asset} onChange={setAsset} options={assets.map((x) => ({ id: x.id, label: x.symbol }))} /></div>70        <Field label={t('destination')}>71          <textarea className={`${inputCls} min-h-[84px] resize-none break-all font-mono text-[14px]`} value={to} onChange={(e) => setTo(e.target.value.replace(/\s/g, ''))} autoCapitalize="off" autoCorrect="off" spellCheck={false} autoComplete="off" />72        </Field>73        {n?.family === 'evm' && <AgentRecipientNotice to={to} network={net} networkName={n.name} />}74        {(book.data ?? []).filter((b) => b.network === net).length > 0 && <div><div className="mb-1.5 text-[13px] text-mute">{t('addressBook')}</div>75          <div className="flex flex-wrap gap-2">{book.data!.filter((b) => b.network === net).map((b) => <button key={b.id} onClick={() => setTo(b.address)} className={`rounded-full border px-3 py-1.5 text-[13px] ${to === b.address ? 'border-emerald text-emerald' : 'border-line'}`}><Icon n="book" className="mr-1 inline h-3.5 w-3.5" />{b.label} · {short(b.address)}</button>)}</div></div>}76        <Field label={`${t('amount')} (${a?.symbol ?? ''})`} hint={bal?.display != null ? <>{t('available')}{t('available') === 'Disponible' ? ' : ' : ': '}{trimAmt(bal.display, 8)} {bal.symbol}</> : bal ? <span className="text-warn">{t('qaBalUnknown')}</span> : undefined}>77          <div className="flex gap-2">78            <input className={`${inputCls} text-lg`} inputMode="decimal" placeholder="0.00" value={amount} onChange={(e) => setAmount(e.target.value.replace(',', '.').replace(/[^0-9.]/g, ''))} autoComplete="off" />79            {asset !== 'native' && bal?.display != null && <Btn kind="soft" onClick={() => setAmount(bal.display!)}>{t('max')}</Btn>}80          </div>81        </Field>82        {n?.memo && <Field label={t(n.memo === 'uint32' ? 'memoTag' : 'memoText')} hint={t('memoWarn')}>83          <input className={inputCls} value={memo} maxLength={n.memo === 'uint32' ? 10 : n.id === 'stellar' ? 28 : 120} inputMode={n.memo === 'uint32' ? 'numeric' : 'text'} onChange={(e) => setMemo(n.memo === 'uint32' ? e.target.value.replace(/\D/g, '') : e.target.value)} autoComplete="off" autoCapitalize="off" autoCorrect="off" spellCheck={false} /></Field>}84        <Field label={t('note')}><input className={inputCls} value={note} maxLength={80} onChange={(e) => setNote(e.target.value)} /></Field>85        {err && <Notice tone="danger">{err}</Notice>}86        <Btn full busy={busy === 'review'} disabled={!to || !amount || pf.data?.halted} onClick={review}>{busy === 'review' ? t('preparing') : t('review')}</Btn>87      </Card>88    </div>89  );9091  if (!cur) return null;92  const needCheck = cur.warnings.includes('new_destination') || cur.warnings.includes('address_poisoning_suspected');93  if (step === 'review') return (94    <div className="space-y-4">95      <h1 className="pt-2 text-[30px] font-semibold tracking-[-0.02em]">{t('reviewTitle')}</h1>96      <Card className="space-y-4">97        <div><div className="text-[13px] text-mute">{t('youSend')}</div>98          <div className="font-[family-name:var(--font-display)] text-[38px] font-semibold leading-tight tracking-[-0.03em]">{cur.amountDisplay} <span className="text-mute">{cur.symbol}</span></div>99          <div className="font-mono text-xs text-mute">{cur.amount} {t('baseUnits')} · {cur.networkName}</div></div>100        <Row k={t('to')}><Addr value={cur.to} /></Row>101        <AgentRecipientNotice to={cur.to} network={cur.network} networkName={cur.networkName} />102        {cur.memo && <Row k={t('memo')}><span className="break-all font-mono text-[14px]">{cur.memo}</span></Row>}103        <Row k={t('from')}><span className="font-mono text-[13px] text-mute">{short(cur.from)}</span></Row>104        <Row k={t('network')}>{cur.networkName}</Row>105        <Row k={t('fee')}>{cur.feeDisplay ? `≈ ${trimAmt(cur.feeDisplay, 9)} ${cur.feeSymbol}` : '—'}</Row>106        <Row k={t('feeMax')}>{cur.maxFeeDisplay ? `${trimAmt(cur.maxFeeDisplay, 9)} ${cur.feeSymbol}` : '—'}</Row>107        {cur.intentHash && <Row k={t('fingerprint')}><span className="break-all font-mono text-[11px] text-mute">{cur.intentHash}</span></Row>}108      </Card>109      <Notice tone="ok">{t('simulated')}</Notice>110      {cur.warnings.map((w) => <Notice key={w} tone={w === 'address_poisoning_suspected' ? 'danger' : 'warn'}>{t(`w_${w}` as Key)}</Notice>)}111      <Notice tone="info">{t('boundNote')} {t('irreversible')}</Notice>112      {needCheck && <label className="flex items-start gap-3 rounded-xl border border-line p-3 text-[14px]"><input type="checkbox" className="mt-0.5 h-5 w-5 accent-[var(--primary)]" checked={checked} onChange={(e) => setChecked(e.target.checked)} />{t('iChecked')}</label>}113      {err && <Notice tone="danger">{err}</Notice>}114      <Btn full busy={!!busy} disabled={needCheck && !checked} onClick={approve}><Icon n="key" />{busy === 'passkey' ? t('approving') : busy === 'prepare' ? t('preparing') : t('approve')}</Btn>115      <Btn full kind="ghost" disabled={!!busy} onClick={cancel}>{t('cancel')}</Btn>116    </div>117  );118119  const idx = FLOW.indexOf(cur.state), bad = idx < 0;120  return (121    <div className="space-y-4">122      <h1 className="pt-2 text-[30px] font-semibold tracking-[-0.02em]">{t('tracking')}</h1>123      <Card className="space-y-4">124        <div className="flex items-start justify-between gap-3"><div><div className="text-2xl font-semibold">{cur.amountDisplay} {cur.symbol}</div><div className="text-xs text-mute">{cur.networkName} → {short(cur.to)}</div></div>{testNotSent(cur) ? <span className="rounded-full bg-warn/12 px-2 py-0.5 text-[11.5px] font-semibold text-warn">{t('qaSignedNotSent')}</span> : <StatePill state={cur.state} />}</div>125        {testNotSent(cur) && <Notice tone="warn">{t('qaTestNotSent')}</Notice>}126        {!bad && !testNotSent(cur) && <ol className="space-y-2.5" aria-label={t('status')}>{FLOW.slice(1).map((s, k) => { const done = idx > k + 1 || cur.state === 'finalized', now = idx === k + 1 && cur.state !== 'finalized';127          return <li key={s} className={`flex items-center gap-3 text-[14.5px] ${done ? 'text-emerald' : now ? 'text-ink' : 'text-mute'}`}>128            <span className={`grid h-6 w-6 place-items-center rounded-full border ${done ? 'border-emerald' : now ? 'border-blue text-blue' : 'border-line'}`}><Icon n={done ? 'check' : now ? 'spin' : 'clock'} className="h-3.5 w-3.5" /></span>129            {t(`s_${s}` as Key)}{s === 'confirming' && cur.confirmations > 0 ? ` · ${cur.confirmations} ${t('confirmations')}` : ''}</li>; })}</ol>}130        {cur.error && bad && <Notice tone="danger">{cur.error}</Notice>}131        {!bad && cur.state !== 'finalized' && !testNotSent(cur) && <p className="text-xs text-mute">{t('broadcastNote')}</p>}132        {cur.txid && <Row k={t('txid')}><span className="break-all font-mono text-[11.5px]">{cur.txid}</span></Row>}133        {cur.explorer && !testNotSent(cur) && <a className="inline-flex min-h-11 items-center gap-1.5 text-sm text-blue" href={cur.explorer} target="_blank" rel="noreferrer noopener"><Icon n="ext" className="h-4 w-4" />{t('viewExplorer')}</a>}134      </Card>135      {cur.state === 'finalized' && !(book.data ?? []).some((b) => b.network === cur.network && b.address === cur.to) && <SaveAddr network={cur.network} address={cur.to} onSaved={() => void book.refetch()} />}136      <div className="grid grid-cols-2 gap-3"><Btn kind="soft" onClick={reset}>{t('newTransfer')}</Btn><Btn kind="ghost" onClick={() => go('activity')}>{t('activity')}</Btn></div>137    </div>138  );139}140141function Row({ k, children }: { k: string; children: React.ReactNode }) {142  return <div className="border-t border-line pt-3"><div className="mb-0.5 text-[12.5px] text-mute">{k}</div><div>{children}</div></div>;143}144function SaveAddr({ network, address, onSaved }: { network: string; address: string; onSaved: () => void }) {145  const t = useT(); const [label, setLabel] = useState('');146  return <Card><H>{t('saveAddress')}</H><div className="flex gap-2"><input className={inputCls} placeholder={t('label')} value={label} maxLength={40} onChange={(e) => setLabel(e.target.value)} />147    <Btn kind="soft" disabled={!label.trim()} onClick={() => void post('/addressbook', { network, address, label: label.trim() }).then(onSaved)}>{t('save')}</Btn></div></Card>;148}149
Files main
Menu

Theme