SPB Git

Repository Public

OrderPanel.tsx 23.1 KB 228 lines tsx

1/**2 * Order panel — PAPER ONLY. It builds an `OrderRequest` with exact decimals (size on the lot, prices on the tick), shows the3 * server's preview (risk verdict + estimates) and, after a confirmation sheet, sends the order to the paper engine. There is no4 * live path in this file: a venue account gets `LiveOrderPanel` instead. The panel reads the mark from the store when it needs5 * it; it does not re-render on every tick.6 */7import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query';8import { useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';9import { errText, I18n, useT, type Key } from '../i18n';10import { Btn, Field, inputCls, Notice } from '../ui';11import { dapi, paperAllowed, riskRefusal } from './api';12import { PaperTag, Row, Seg, ServerSays, Sheet, SideTag, VENUE_NAME } from './bits';13import { D, dec, dpOf, fmt, onTick, parseInput, priceDp, usd, usdToSize, worstPrice, type Dec } from './dec';14import { clearPaperPrefill, peekPaperPrefill } from './routerApi';15import type { MarketStore } from './store';16import type { DerivAccount, Instrument, OrderBody, Placed, VenueInfo } from './types';1718/** protection of market-like orders: a market order is bounded by the server at this distance from the book (`slippageBps`); a triggered order carries a worst price this far from its trigger */19export const SLIPPAGE_BPS = 50;20type Kind = 'market' | 'limit' | 'stop' | 'tp';21const TYPE: Record<Kind, OrderBody['type']> = { market: 'market', limit: 'limit', stop: 'stop_market', tp: 'take_profit_market' };22interface Form { side: 'buy' | 'sell'; kind: Kind; unit: 'base' | 'usd'; size: string; lev: number; price: string; trigger: string; reduceOnly: boolean; postOnly: boolean; sl: string; tp: string }23interface Built { body: OrderBody | null; problem: Key | null; sl: Dec | null; tp: Dec | null; ref: Dec | null }2425function build(f: Form, inst: Instrument, mark: Dec | null): Built {26  const none = (problem: Key | null): Built => ({ body: null, problem, sl: null, tp: null, ref: null });27  const tick = dec(inst.tickSize), lot = dec(inst.lotSize);28  if (!tick || !lot || !D.isPos(tick) || !D.isPos(lot)) return none('fxErrInstrument');29  if (!mark || !D.isPos(mark)) return none('fxErrNoPrice');30  const v = inst.venue;31  let price: Dec | null = null, trigger: Dec | null = null, ref = mark;32  if (f.kind === 'limit') { const p = parseInput(f.price); if (!p) return none(f.price.trim() ? 'fxErrPrice' : null); price = onTick(D.from(p), tick, v); if (!D.isPos(price)) return none('fxErrPrice'); ref = price; }33  else if (f.kind === 'market') price = null;   // the server derives the worst price from the live book (`slippageBps`)34  else { const p = parseInput(f.trigger); if (!p) return none(f.trigger.trim() ? 'fxErrTrigger' : null); trigger = onTick(D.from(p), tick, v); if (!D.isPos(trigger)) return none('fxErrTrigger'); price = worstPrice(trigger, f.side, SLIPPAGE_BPS, tick, v); ref = trigger; }35  const raw = parseInput(f.size);36  if (!raw) return none(f.size.trim() ? 'fxErrSize' : null);37  const size = f.unit === 'usd' ? usdToSize(D.from(raw), ref, lot) : D.quantize(D.from(raw), lot, 'down');38  if (!D.isPos(size)) return none('fxErrSizeSmall');39  let sl: Dec | null = null, tp: Dec | null = null;40  if (f.kind === 'market' && !f.reduceOnly) {41    if (f.sl.trim()) { const p = parseInput(f.sl); if (!p) return none('fxErrSl'); sl = onTick(D.from(p), tick, v); if (!D.isPos(sl) || (f.side === 'buy' ? D.gte(sl, mark) : D.lte(sl, mark))) return none(f.side === 'buy' ? 'fxErrSlLong' : 'fxErrSlShort'); }42    if (f.tp.trim()) { const p = parseInput(f.tp); if (!p) return none('fxErrTp'); tp = onTick(D.from(p), tick, v); if (!D.isPos(tp) || (f.side === 'buy' ? D.lte(tp, mark) : D.gte(tp, mark))) return none(f.side === 'buy' ? 'fxErrTpLong' : 'fxErrTpShort'); }43  }44  return { body: { instrument: inst.id, side: f.side, type: TYPE[f.kind], size: D.str(size), price: price ? D.str(price) : null, triggerPrice: trigger ? D.str(trigger) : null, reduceOnly: f.reduceOnly,45    tif: f.kind === 'market' ? 'ioc' : f.kind === 'limit' && f.postOnly ? 'alo' : 'gtc', leverage: String(f.lev), ...(f.kind === 'market' ? { slippageBps: SLIPPAGE_BPS } : {}) }, problem: null, sl, tp, ref };46}47function useDebounced<T>(v: T, ms: number): T { const [d, setD] = useState(v); useEffect(() => { const h = setTimeout(() => setD(v), ms); return () => clearTimeout(h); }, [v, ms]); return d; }48const num = (s: string) => s.replace(/[^0-9.,\s ]/g, '');4950export function RiskVerdict({ code, reason }: { code: string; reason: string }) {51  const t = useT();52  const known = t(`rk_${code}` as Key), text = known === `${code}`.replace(/_/g, ' ') || known.startsWith('rk ') ? t('rk_unknown') : known;53  return <Notice tone="danger"><div data-testid="risk-refusal"><b className="font-semibold">{t('fxRefused')}</b> {text}{reason && <span className="mt-1 block break-words font-mono text-[11.5px] text-mute">{code} — {reason}</span>}</div></Notice>;54}5556export function OrderPanel({ store, instrument, account, accounts, venue, onAccount, goAccounts, switcher }: { store: MarketStore; instrument: Instrument; account: DerivAccount | null; accounts: DerivAccount[]; venue: VenueInfo | undefined; onAccount: (id: string) => void; goAccounts: () => void;57  /** the terminal's account switcher (paper and venue accounts); without it the panel lists its own paper accounts */ switcher?: ReactNode }) {58  const t = useT(); const { locale } = useContext(I18n); const qc = useQueryClient();59  const maxLev = useMemo(() => { const a = dec(instrument.maxLeverage), b = dec(account?.policy.maxLeverage); const m = a && b ? D.min(a, b) : a ?? b ?? D.one; return Math.max(1, Math.floor(D.toNumber(m))); }, [instrument.maxLeverage, account?.policy.maxLeverage]);60  const [f, setF] = useState<Form>(() => {61    const blank: Form = { side: 'buy', kind: 'market', unit: 'usd', size: '', lev: 3, price: '', trigger: '', reduceOnly: false, postOnly: false, sl: '', tp: '' };62    // hand-over from the router (simulation): the form starts filled with that order — still a form, nothing is sent until the owner confirms63    const p = peekPaperPrefill(instrument.id);64    return p ? { ...blank, side: p.side, kind: p.kind, unit: p.unit, size: p.size, lev: Math.max(1, Math.min(p.lev, maxLev)), price: p.price, reduceOnly: p.reduceOnly } : blank;65  });66  useEffect(() => { clearPaperPrefill(instrument.id); }, []);   // eslint-disable-line react-hooks/exhaustive-deps — the hand-over fills the form once67  const set = (p: Partial<Form>) => { setF((x) => ({ ...x, ...p })); setDone(null); setErr(null); };68  const [, setPulse] = useState(0);69  const [confirm, setConfirm] = useState(false), [busy, setBusy] = useState(false);70  const [done, setDone] = useState<{ placed: Placed; extra: string[] } | null>(null), [err, setErr] = useState<{ code?: string; reason?: string; text?: string } | null>(null);71  const lev = Math.min(f.lev, maxLev);72  useEffect(() => { const h = setInterval(() => setPulse((n) => n + 1), 1000); return () => clearInterval(h); }, []);73  // another market: the amounts of the previous one are dropped. Not while the market is the one the panel opened on — the form is blank then, or holds the router's hand-over.74  const shownMarket = useRef(instrument.id);75  useEffect(() => { if (shownMarket.current === instrument.id) return; shownMarket.current = instrument.id; setF((x) => ({ ...x, size: '', price: '', trigger: '', sl: '', tp: '' })); setDone(null); setErr(null); }, [instrument.id]);7677  const mark = dec(store.ticker?.mark);78  const built = build({ ...f, lev }, instrument, mark);79  const latest = useRef(built); latest.current = built;80  const marketLike = f.kind !== 'limit';81  const stable = useDebounced(built.body ? JSON.stringify({ ...built.body, price: marketLike ? null : built.body.price, size: f.unit === 'usd' ? `usd:${f.size}` : built.body.size }) : '', 300);82  const pv = useQuery({ queryKey: ['deriv', 'preview', account?.id, stable], enabled: !!account && !!stable && !confirm,83    queryFn: () => { const b = latest.current.body; if (!b || !account) throw new Error('no order'); // never leave « Chargement… » on screen for ever: a preview that takes more than 10 s counts as unavailable and is retried84      return Promise.race([dapi.preview(account.id, b), new Promise<never>((_, no) => setTimeout(() => no(new Error('timeout')), 10_000))]); }, refetchInterval: 4000, placeholderData: keepPreviousData, retry: 0, staleTime: 0, gcTime: 0 });85  const preview = built.body && stable ? pv.data : undefined;86  const pdp = priceDp(instrument.tickSize, store.ticker?.mark), sdp = dpOf(instrument.lotSize);8788  const submit = async () => {89    const b = latest.current; if (!b.body || !account) return;90    setBusy(true); setErr(null);91    try {92      const placed = await dapi.placeOrder(account.id, b.body, crypto.randomUUID());93      const extra: string[] = [];94      const filled = placed.fills.reduce((a, x) => D.add(a, dec(x.size) ?? D.zero), D.zero);95      if (D.isPos(filled) && (b.sl || b.tp)) {96        const tick = D.from(instrument.tickSize), exit = b.body.side === 'buy' ? 'sell' as const : 'buy' as const;97        for (const [trig, type, name] of [[b.sl, 'stop_market', t('fxStopLoss')], [b.tp, 'take_profit_market', t('fxTakeProfit')]] as const) {98          if (!trig) continue;99          try { await dapi.placeOrder(account.id, { instrument: instrument.id, side: exit, type, size: D.str(filled), price: D.str(worstPrice(trig, exit, SLIPPAGE_BPS, tick, instrument.venue)), triggerPrice: D.str(trig), reduceOnly: true, tif: 'gtc' }, crypto.randomUUID()); }100          catch (e) { const r = riskRefusal(e); extra.push(t('fxAttachedFailed', { name, why: r ? t(`rk_${r.code}` as Key) : errText(t, e) })); }101        }102      }103      setDone({ placed, extra }); setConfirm(false); setF((x) => ({ ...x, size: '', sl: '', tp: '' }));104    } catch (e) { const r = riskRefusal(e); setErr(r ?? { text: errText(t, e) || t('fxErrGeneric') }); setConfirm(false); }105    finally { setBusy(false); void qc.invalidateQueries({ queryKey: ['deriv', 'accounts'] }); void qc.invalidateQueries({ queryKey: ['deriv', 'account'] }); }106  };107108  // ---- cases where nothing can be simulated: say why, offer nothing that pretends109  if (!paperAllowed(venue)) return (110    <div className="space-y-3" data-testid="order-panel">111      <h3 className="text-[15px] font-semibold">{t('fxOrder')}</h3>112      <Notice tone="warn">{t(instrument.venue === 'dydx' ? 'fxNoPaper_dydx' : instrument.venue === 'gmx' ? 'fxNoPaper_gmx' : 'fxNoPaper_other', { venue: VENUE_NAME[instrument.venue] ?? instrument.venue })}113        {venue?.restriction && <ServerSays text={venue.restriction} className="mt-1.5 text-[12px] text-mute" />}</Notice>114    </div>);115  if (!account) return (116    <div className="space-y-3" data-testid="order-panel">117      <h3 className="text-[15px] font-semibold">{t('fxOrder')}</h3>118      <Notice tone="info">{t('fxNoAccount', { venue: VENUE_NAME[instrument.venue] })}</Notice>119      <Btn full kind="soft" onClick={goAccounts}>{t('fxCreateAccount')}</Btn>120    </div>);121122  const est = preview?.estimate ?? null, refused = preview && preview.risk && preview.risk.ok === false ? preview.risk : null;123  // the paper engine's own answer on a copy of the account: a rejection there is not a risk refusal (post-only would cross, margin at fill…)124  const wouldReject = !refused && est?.status === 'rejected' ? est.rejectReason ?? '' : null;125  const allowed = account.policy.allowedInstruments.includes(instrument.id);126  const allow = async () => { setBusy(true); setErr(null); try { await dapi.setPolicy(account.id, { ...account.policy, allowedInstruments: [...account.policy.allowedInstruments, instrument.id] }); await qc.invalidateQueries({ queryKey: ['deriv', 'accounts'] }); } catch (e) { setErr({ text: errText(t, e) || t('fxErrGeneric') }); } finally { setBusy(false); } };127  const long = f.side === 'buy';128  const sizeNote = built.body ? `${fmt(built.body.size, { dp: sdp, locale, trim: true })} ${instrument.base}${built.ref ? ` ≈ ${usd(D.mul(D.from(built.body.size), built.ref), locale)}` : ''}` : null;129  const sideLabel = (s: 'buy' | 'sell') => <span className="inline-flex items-center gap-1.5"><svg viewBox="0 0 12 12" className="h-2.5 w-2.5" aria-hidden="true"><path d={s === 'buy' ? 'M6 2l4.5 7h-9z' : 'M6 10L1.5 3h9z'} fill="currentColor" /></svg>{t(s === 'buy' ? 'fxLong' : 'fxShort')}</span>;130  const check = (on: boolean, onChange: (v: boolean) => void, label: string, hint?: string) => <label className="flex min-h-11 cursor-pointer items-center gap-2.5 text-[13.5px]"><input type="checkbox" checked={on} onChange={(e) => onChange(e.target.checked)} className="h-5 w-5 shrink-0 accent-[var(--blue)]" /><span>{label}{hint && <span className="block text-[11.5px] text-mute">{hint}</span>}</span></label>;131  const summary = built.body && <>132    <Row label={t('fxDirection')}><SideTag side={long ? 'long' : 'short'} /></Row>133    <Row label={t('fxOrderType')}>{t(`fxKind_${f.kind}` as Key)}{f.reduceOnly ? ` · ${t('fxReduceOnly')}` : ''}{built.body.tif === 'alo' ? ` · ${t('fxPostOnly')}` : ''}</Row>134    <Row label={t('fxSize')}>{sizeNote}</Row>135    {f.kind === 'limit' && <Row label={t('fxLimitPrice')}>{fmt(built.body.price, { dp: pdp, locale })}</Row>}136    {built.body.triggerPrice && <Row label={t('fxTriggerPrice')}>{fmt(built.body.triggerPrice, { dp: pdp, locale })}</Row>}137    {marketLike && <Row label={t('fxWorstPrice')}>{fmt(built.body.price ?? est?.price ?? preview?.request?.price ?? null, { dp: pdp, locale })}</Row>}138    <Row label={t('fxLeverage')}>{lev}×</Row>139    {built.sl && <Row label={t('fxStopLoss')}>{fmt(D.str(built.sl), { dp: pdp, locale })}</Row>}140    {built.tp && <Row label={t('fxTakeProfit')}>{fmt(D.str(built.tp), { dp: pdp, locale })}</Row>}141  </>;142  const estimates = est && <>143    <Row label={t('fxNotional')}>{usd(est.notional, locale)}</Row>144    <Row label={t('fxInitialMargin')}>{usd(est.initialMargin, locale)}</Row>145    <Row label={t('fxFee')}>{usd(est.fee, locale)}</Row>146    <Row label={t('fxAvgPrice')}>{est.avgFillPrice ? fmt(est.avgFillPrice, { dp: pdp, locale }) : '—'}</Row>147    <Row label={t('fxSlippage')}>{est.slippageBps ? `${fmt(est.slippageBps, { dp: 1, locale, sign: true })} ${t('fxBps')}` : '—'}</Row>148    <Row label={<>{t('fxLiqPrice')} <em className="not-italic text-warn">({t('estimate')})</em></>}>{est.liquidationPrice?.value ? fmt(est.liquidationPrice.value, { dp: pdp, locale }) : '—'}</Row>149    <Row label={t('fxFreeAfter')}>{usd(est.freeCollateralAfter, locale)}</Row>150  </>;151152  return (153    <div className="space-y-3.5" data-testid="order-panel">154      <div className="flex items-center justify-between gap-2"><h3 className="text-[15px] font-semibold">{t('fxOrder')}</h3><PaperTag /></div>155      <div className="rounded-2xl bg-elev/70 p-3">156        {switcher ?? (accounts.length > 1157          ? <label className="block text-[12px] text-mute">{t('fxAccount')}<select aria-label={t('fxAccount')} value={account.id} onChange={(e) => onAccount(e.target.value)} className="mt-1 min-h-11 w-full rounded-xl border border-line bg-surface px-3 text-[14px] text-ink">{accounts.map((a) => <option key={a.id} value={a.id}>{a.label}</option>)}</select></label>158          : <div className="truncate text-[13px] font-semibold">{account.label}</div>)}159        <div className="mt-1.5 flex items-baseline justify-between text-[12.5px]"><span className="text-mute">{t('fxFreeCollateral')}</span><span className="font-semibold" data-testid="free-collateral">{usd(account.state.freeCollateral, locale)}</span></div>160        <div className="flex items-baseline justify-between text-[12.5px]"><span className="text-mute">{t('fxEquity')}</span><span className="font-medium">{usd(account.state.equity, locale)}</span></div>161      </div>162      {account.policy.halt && <Notice tone="danger">{t('fxHalted')} <span className="font-mono text-[12px]">{account.policy.halt}</span></Notice>}163      {instrument.status !== 'active' && <Notice tone="warn">{t(instrument.status === 'halted' ? 'fxInstHalted' : 'fxInstReduceOnly')}</Notice>}164      {!allowed && <Notice tone="warn"><div data-testid="not-allowed">{t('fxNotAllowed', { symbol: instrument.symbol })}165        <button type="button" disabled={busy} onClick={() => void allow()} className="mt-1 block min-h-11 text-left font-semibold underline">{t('fxAllow', { symbol: instrument.symbol })}</button></div></Notice>}166167      <div role="radiogroup" aria-label={t('fxDirection')} className="grid grid-cols-2 gap-2">168        {(['buy', 'sell'] as const).map((s) => <button key={s} type="button" role="radio" aria-checked={f.side === s} onClick={() => set({ side: s })}169          className={`min-h-12 rounded-2xl border text-[15px] font-semibold transition ${f.side === s ? (s === 'buy' ? 'border-emerald bg-emerald text-white' : 'border-danger bg-danger text-white') : 'border-line text-mute hover:text-ink'}`}>{sideLabel(s)}</button>)}170      </div>171      <Seg size="sm" label={t('fxOrderType')} value={f.kind} onChange={(kind) => set({ kind })} options={(['market', 'limit', 'stop', 'tp'] as const).map((k) => ({ id: k, label: t(`fxKindShort_${k}` as Key), aria: t(`fxKind_${k}` as Key) }))} />172173      {f.kind === 'limit' && <Field label={`${t('fxLimitPrice')} (${instrument.quote})`}><input className={inputCls} inputMode="decimal" autoComplete="off" value={f.price} placeholder={mark ? fmt(mark, { dp: pdp, locale }) : ''} onChange={(e) => set({ price: num(e.target.value) })} /></Field>}174      {(f.kind === 'stop' || f.kind === 'tp') && <Field label={`${t('fxTriggerPrice')} (${instrument.quote})`} hint={t(f.kind === 'stop' ? 'fxStopHint' : 'fxTpHint')}><input className={inputCls} inputMode="decimal" autoComplete="off" value={f.trigger} onChange={(e) => set({ trigger: num(e.target.value) })} /></Field>}175      <Field label={t('fxSize')} hint={sizeNote ?? (built.problem ? <span className="text-danger">{t(built.problem)}</span> : t('fxSizeHint', { lot: fmt(instrument.lotSize, { locale, trim: true }), base: instrument.base }))}>176        <div className="flex gap-2">177          <input className={`${inputCls} min-w-0 flex-1`} inputMode="decimal" autoComplete="off" aria-label={t('fxSize')} value={f.size} placeholder="0" onChange={(e) => set({ size: num(e.target.value) })} />178          <Seg size="sm" label={t('fxSizeUnit')} value={f.unit} onChange={(unit) => set({ unit, size: '' })} options={[{ id: 'usd', label: 'USD' }, { id: 'base', label: instrument.base.slice(0, 6) }]} className="shrink-0" />179        </div>180      </Field>181      <div>182        <div className="mb-1 flex items-baseline justify-between text-[13px]"><label htmlFor="fx-lev" className="text-mute">{t('fxLeverage')}</label><span className="font-semibold">{lev}×</span></div>183        <input id="fx-lev" type="range" min={1} max={maxLev} step={1} value={lev} disabled={maxLev <= 1} onChange={(e) => set({ lev: Number(e.target.value) })} className="h-11 w-full accent-[var(--blue)]" aria-valuetext={`${lev}×`} />184        <p className="text-[11.5px] leading-snug text-mute">{t('fxLeverageBounds', { policy: fmt(account.policy.maxLeverage, { locale, trim: true }), venue: fmt(instrument.maxLeverage, { locale, trim: true }) })}</p>185      </div>186      <div className="grid grid-cols-1 gap-x-4 sm:grid-cols-2 xl:grid-cols-1">187        {check(f.reduceOnly, (reduceOnly) => set({ reduceOnly }), t('fxReduceOnly'), t('fxReduceOnlyHint'))}188        {f.kind === 'limit' && check(f.postOnly, (postOnly) => set({ postOnly }), t('fxPostOnly'), t('fxPostOnlyHint'))}189      </div>190      {f.kind === 'market' && !f.reduceOnly && <fieldset className="rounded-2xl border border-line p-3"><legend className="px-1 text-[12.5px] text-mute">{t('fxAttached')}</legend>191        <div className="grid grid-cols-2 gap-2">192          <Field label={t('fxStopLoss')}><input className={inputCls} inputMode="decimal" autoComplete="off" value={f.sl} onChange={(e) => set({ sl: num(e.target.value) })} /></Field>193          <Field label={t('fxTakeProfit')}><input className={inputCls} inputMode="decimal" autoComplete="off" value={f.tp} onChange={(e) => set({ tp: num(e.target.value) })} /></Field>194        </div><p className="mt-1.5 text-[11.5px] leading-snug text-mute">{t('fxAttachedHint')}</p></fieldset>}195      {marketLike && <p className="text-[11.5px] leading-snug text-mute">{t('fxSlippageNote', { pct: fmt(String(SLIPPAGE_BPS / 100), { locale, trim: true }) })}</p>}196197      {built.body && <div className="rounded-2xl border border-line p-3" aria-live="polite" data-testid="preview">198        <div className="mb-1 flex items-center justify-between"><span className="text-[12.5px] font-semibold">{t('fxPreview')}</span><PaperTag /></div>199        {est ? estimates : <p className="py-2 text-[12.5px] text-mute">{pv.isError ? t('fxPreviewError') : t('fxLoading')}</p>}200        {est && <p className="mt-1 text-[11.5px] leading-snug text-mute">{t('fxEstimateNote')}{est.liquidationPrice?.method ? <span className="block">{t('fxMethod')} {est.liquidationPrice.method}</span> : null}</p>}201      </div>}202      {refused && (allowed || refused.code !== 'instrument_not_allowed') && <RiskVerdict code={refused.code} reason={refused.reason} />}203      {wouldReject !== null && <Notice tone="warn"><div data-testid="would-reject">{t('fxWouldReject')}{wouldReject && <span className="mt-1 block break-words font-mono text-[11.5px] text-mute">{wouldReject}</span>}</div></Notice>}204      {err && (err.code ? <RiskVerdict code={err.code} reason={err.reason ?? ''} /> : <Notice tone="danger">{err.text}</Notice>)}205      {done && <Notice tone={done.placed.order.status === 'rejected' || done.placed.order.status === 'cancelled' ? 'warn' : 'ok'}><div data-testid="order-result">206        {t(`fxPlaced_${done.placed.order.status}` as Key, { size: fmt(done.placed.order.filled, { dp: sdp, locale, trim: true }), base: instrument.base, price: done.placed.order.avgFillPrice ? fmt(done.placed.order.avgFillPrice, { dp: pdp, locale }) : '—' })}207        {done.placed.order.rejectReason && <span className="mt-1 block font-mono text-[11.5px] text-mute">{done.placed.order.rejectReason}</span>}208        {done.extra.map((x) => <span key={x} className="mt-1 block text-warn">{x}</span>)}</div></Notice>}209210      <button type="button" disabled={!built.body || !!refused || wouldReject !== null || !preview || instrument.status === 'halted'} onClick={() => setConfirm(true)}211        className={`inline-flex min-h-[50px] w-full items-center justify-center gap-2 rounded-2xl px-5 text-[15.5px] font-semibold text-white transition active:scale-[0.98] disabled:opacity-45 ${long ? 'bg-emerald' : 'bg-danger'}`}>212        {sideLabel(f.side)}<span>· {t('fxReview', { base: instrument.base })}</span></button>213      <p className="text-center text-[11.5px] text-mute">{t('fxPaperOnly')}</p>214215      {confirm && built.body && <Sheet title={t('fxConfirmTitle')} onClose={() => !busy && setConfirm(false)}>216        <div className="space-y-3">217          <div className="flex items-center gap-2"><PaperTag /><span className="text-[13px] text-mute">{account.label} · {VENUE_NAME[instrument.venue]} · {instrument.symbol}</span></div>218          <div className="rounded-2xl bg-elev/70 p-3">{summary}</div>219          {est && <div className="rounded-2xl border border-line p-3">{estimates}</div>}220          <Notice tone="info">{t('fxConfirmNote')}</Notice>221          <Btn full busy={busy} onClick={() => void submit()}>{t('fxConfirm')}</Btn>222          <Btn full kind="ghost" disabled={busy} onClick={() => setConfirm(false)}>{t('cancel')}</Btn>223        </div>224      </Sheet>}225    </div>226  );227}228
Files main
Menu

Theme