Repository Public
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