// Trade Ticket — order dialog (design D, live) // Colored circle · Market/Limit/Stop · entry/stop price hero · attach TP/SL · // footer VWAP·Amount·Margin · joined Sell/Buy bar. const TT_CAT_COLOR = { FX: '#3B82F6', Metals: '#E0A21A', Energy: '#E2563B', Indices: '#8B5CF6', Crypto: '#F7931A' }; // 3-part price (same format as Market Watch): base 75% · pips 100% · frac 50% function TPx({ value, inst, size = 30, color = '#fff', weight = 700 }) { const { base, pips, frac } = splitPip(value, inst.digits, inst.pipDigits); return ( // A 3-part price is three ADJACENT spans, which is exactly the shape // RTL reorders: 1.08|57|4 renders as 4|57|1.08 — an unreadable price, // not a cosmetic difference. Pinning the wrapper to LTR keeps the // parts in order; the surrounding row still mirrors normally (ID5). {base} {pips} {frac && {frac}} ); } // Tap-to-type price: shows the 3-part TPx when idle, a numeric input when focused function EditablePrice({ value, setValue, inst, size = 30, color, theme, width = 160 }) { const [editing, setEditing] = React.useState(false); const [text, setText] = React.useState(''); if (editing) { return ( setText(e.target.value.replace(/[^0-9.]/g, ''))} onBlur={() => { let n = parseFloat(text); if (isNaN(n)) n = value; setValue(+n.toFixed(inst.digits)); setEditing(false); }} onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }} style={{ width, textAlign: 'center', background: 'transparent', border: 'none', borderBottom: `2px solid ${color || theme.text}`, outline: 'none', color: color || theme.text, fontSize: size, fontWeight: 700, fontVariantNumeric: 'tabular-nums', fontFamily: 'inherit', padding: '0 0 2px', }} /> ); } return (
{ setText(value.toFixed(inst.digits)); setEditing(true); }} style={{ cursor: 'text', display: 'flex', justifyContent: 'center' }}>
); } // Tap-to-type volume (plain decimal, 2dp) function EditableVol({ vol, setVol, theme, fontSize, width = 150 }) { const [editing, setEditing] = React.useState(false); const [text, setText] = React.useState(''); if (editing) { return ( setText(e.target.value.replace(/[^0-9.]/g, ''))} onBlur={() => { let n = parseFloat(text); if (isNaN(n)) n = vol; n = Math.max(0.01, Math.min(50, Math.round(n * 100) / 100)); setVol(n); setEditing(false); }} onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }} style={{ width, textAlign: 'center', background: 'transparent', border: 'none', borderBottom: `2px solid ${theme.text}`, outline: 'none', color: theme.text, fontSize, fontWeight: 700, letterSpacing: -1, fontVariantNumeric: 'tabular-nums', fontFamily: 'inherit', padding: '0 0 2px', }} /> ); } return (
{ setText(vol.toFixed(2)); setEditing(true); }} style={{ fontSize, fontWeight: 700, color: theme.text, fontVariantNumeric: 'tabular-nums', letterSpacing: -1, minWidth: width, cursor: 'text' }}> {vol.toFixed(2)}
); } // StepBtn / PriceHero live at MODULE scope on purpose: the ticket re-renders // on every price tick, and a component defined inside the render function is // a NEW component type each time — React unmounts and remounts its DOM every // tick, eating any click whose mousedown/mouseup straddles a render. That // made the +/− steppers unresponsive while tap-to-type (stable top-level // EditableVol/EditablePrice) kept working. function TTStepBtn({ theme, big, onClick, children }) { return (
{children}
); } function PriceHero({ theme, inst, label, value, setValue, onMinus, onPlus, sub, valueSize = 30, big }) { return (
{label}
+
{sub &&
{sub}
}
); } // ——— P6: agent prefill (PD11) ——— // // `prefill` fills this sheet in. It cannot submit it. The sheet is mounted per // open, so unlike the desktop ticket there is no re-seed to fight — the values // below are simply the initial state when an agent proposed them. // // The banner sits directly above the confirm button, which on this shell is the // bottom of a sheet the human's thumb is already near. Nothing autofocuses and // nothing is auto-pressed: opening a filled ticket is showing, not submitting. function TradeTicket({ inst, priceState, theme, onClose, onConfirm, initialSide = 'buy', prefill = null }) { const c = TT_CAT_COLOR[inst.cat] || theme.accent; const pip = Math.pow(10, -inst.pipDigits); // one pip const tick = Math.pow(10, -inst.digits); const round = (v) => +v.toFixed(inst.digits); const side = initialSide; // fixed — chosen on the previous screen const isBuy = side === 'buy'; const marketPrice = isBuy ? priceState.ask : priceState.bid; const [type, setType] = React.useState(() => (prefill && prefill.orderType) || 'Market'); // Market | Limit | Stop const [vol, setVol] = React.useState(() => (prefill && prefill.vol > 0 ? prefill.vol : 0.10)); // Who filled this in, when it was not the person holding the phone. Dismissed // explicitly; it does NOT clear the numbers, because "I have read this" must // not destroy the thing that was read. const [agentFill, setAgentFill] = React.useState(() => (prefill ? { by: prefill.by } : null)); const [phase, setPhase] = React.useState('form'); // form | confirming | done | error const [err, setErr] = React.useState(null); // entry/stop price — seeded a sensible distance & direction from market const seedPrice = () => { // Limit = better price than market (buy below / sell above); Stop = worse (buy above / sell below) if (type === 'Limit') return round(isBuy ? priceState.ask - 50 * pip : priceState.bid + 50 * pip); if (type === 'Stop') return round(isBuy ? priceState.ask + 50 * pip : priceState.bid - 50 * pip); return marketPrice; }; // An agent's own level wins over the seeded default, and it is read from the // field the order type actually uses — a STOP's trigger in the limit box is a // ticket that places the wrong order when a human presses the button. const prefillLevel = prefill ? (prefill.orderType === 'Stop' ? prefill.stopPrice : prefill.orderType === 'Limit' ? prefill.price : 0) : 0; const [entry, setEntry] = React.useState(() => (prefillLevel > 0 ? round(prefillLevel) : seedPrice())); // Skip the first run when an agent supplied the level, or the type it set // would immediately re-seed over it. const seededFromAgent = React.useRef(prefillLevel > 0); React.useEffect(() => { if (seededFromAgent.current) { seededFromAgent.current = false; return; } setEntry(seedPrice()); /* reseed on type change */ }, [type]); // TP / SL — directional defaults (buy: TP above, SL below; sell: reversed) // A zero from an agent means "no level", exactly as it does on the placement // API — so an unattached stop stays unattached rather than becoming zero. const [tpOn, setTpOn] = React.useState(() => !!(prefill && prefill.tp > 0)); const [slOn, setSlOn] = React.useState(() => !!(prefill && prefill.sl > 0)); const refPrice = type === 'Market' ? marketPrice : entry; const [tp, setTp] = React.useState(() => (prefill && prefill.tp > 0 ? round(prefill.tp) : round(isBuy ? priceState.ask + 80 * pip : priceState.bid - 80 * pip))); const [sl, setSl] = React.useState(() => (prefill && prefill.sl > 0 ? round(prefill.sl) : round(isBuy ? priceState.bid - 80 * pip : priceState.ask + 80 * pip))); const adjVol = (d) => setVol(v => Math.max(0.01, Math.min(50, Math.round((v + d) * 100) / 100))); // derived numbers — real contract size, real leverage, quote-ccy-aware. // The notional of GBP/JPY is JPY; it is only a $ figure after conversion // through the live crosses (quoteToUSD). No conversion streaming → show // the honest quote-currency amount instead of a fake $. const execPrice = type === 'Market' ? marketPrice : entry; const q2u = quoteToUSD(inst); // USD per quote unit; 0 = unknown const notionalQuote = vol * inst.contract * execPrice; const valueUSD = notionalQuote * q2u; const margin = marginEstimate(inst, vol, execPrice); // USD; 0 when no conversion const dir = isBuy ? 1 : -1; const estUSD = (level) => (level - refPrice) * dir * vol * inst.contract * q2u; const tpEst = estUSD(tp); const slEst = estUSD(sl); const pipsAway = Math.round((entry - marketPrice) / pip * 10) / 10; // Isolated like every other money run (ID5) — see theme.jsx fmtMoney. const fmtUSD = (n, dp = 2) => bidiIsolate('$' + Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: dp, maximumFractionDigits: dp })); const fmtPlain = (n, dp = 0) => Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: dp, maximumFractionDigits: dp }); // Live submit: onConfirm(order) → Promise<{ok, status, ticket, error}>. // The resolved status is the PLACEMENT ACK (submitted / held / pending) — // execution is asynchronous and arrives later via the event stream (and // surfaces as fill/partial-fill toasts). The dialog must never claim // "filled" here; it only knows the order was accepted. const [ackStatus, setAckStatus] = React.useState(null); // FR4: `err` is what the user reads (localized from the reject CODE); // `errDetail` carries the server's own English text as a tooltip only. const [errDetail, setErrDetail] = React.useState(null); const submit = async () => { setPhase('confirming'); try { const res = await onConfirm({ inst, side, vol, type, entry: type === 'Market' ? marketPrice : entry, tp: tpOn ? tp : 0, sl: slOn ? sl : 0, }); if (res && res.ok) { setAckStatus(res.status || 'submitted'); setPhase('done'); // The human acted on it, so it is theirs now. setAgentFill(null); uiClearPrefill(); setTimeout(onClose, 1200); } else { const code = res && res.rejectCode; setErr(code ? tCode('reject', code) : ((res && res.error) || t('ticket.rejectedTitle'))); setErrDetail(code ? ((res && res.error) || null) : null); setPhase('error'); } } catch (e) { setErr(String(e.message || e)); setErrDetail(null); setPhase('error'); } }; const ackTitle = () => { if (ackStatus === 'pending') return t('ticket.ackPendingTitle', { kind: t('order.kind.' + type.toLowerCase()) }); if (ackStatus === 'held') return t('ticket.ackAccepted'); return t('ticket.ackSentToMarket'); // submitted — fills arrive asynchronously }; const ackSub = () => { const sideWord = t(isBuy ? 'common.side.buy' : 'common.side.sell'); if (ackStatus === 'pending') return t('ticket.ackPendingSub', { side: sideWord, vol: vol.toFixed(2), price: fmtPrice(entry, inst.digits) }); return t('ticket.ackSentSub', { side: sideWord, vol: vol.toFixed(2) }); }; const sym = inst.sym.length === 6 && inst.sym.match(/^[A-Z]{6}$/) ? inst.sym.slice(0,3)+'/'+inst.sym.slice(3) : inst.sym; return (
e.stopPropagation()} style={{ width: '100%', background: theme.bgElevated, borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: '12px 16px 28px', animation: 'slideUp 280ms cubic-bezier(0.2, 0.9, 0.3, 1.05)', borderTop: `0.5px solid ${theme.border}`, maxHeight: '94%', overflowY: 'auto', }}> {/* grab handle */}
{/* header — colored circle, no icon */}
{sym}
{inst.name}
{phase === 'done' ? (
{ackTitle()}
{ackSub()}
) : phase === 'error' ? (
{t('ticket.rejectedTitle')}
{err}
{ setErr(null); setErrDetail(null); setPhase('form'); }} style={{ marginTop: 16, padding: '11px 26px', borderRadius: 11, display: 'inline-block', background: theme.bgSubtle, color: theme.text, fontWeight: 600, fontSize: 14, cursor: 'pointer' }}>{t('common.action.retry')}
) : ( <> {/* order type */}
{/* the array stays canonical — it is compared against `type` throughout; only the rendered label is localized. */} {['Market', 'Limit', 'Stop'].map(o => (
setType(o)} style={{ flex: 1, textAlign: 'center', padding: '8px 0', borderRadius: 7, fontSize: 13, fontWeight: 600, cursor: 'pointer', background: type === o ? theme.bgCard : 'transparent', color: type === o ? theme.text : theme.textSec, border: type === o ? `0.5px solid ${theme.borderStrong}` : '0.5px solid transparent', }}>{t('order.kind.' + o.toLowerCase())}
))}
{/* entry / stop price hero (Limit & Stop only) */} {type !== 'Market' && ( <> setEntry(v => round(v - pip))} onPlus={() => setEntry(v => round(v + pip))} sub={t(pipsAway >= 0 ? 'ticket.pipsAboveMarket' : 'ticket.pipsBelowMarket', { pips: Math.abs(pipsAway).toFixed(1) })} valueSize={34} big />
)} {/* volume hero */} {/* TP / SL — dashed add-pills, expand to fields */}
setTp(v => round(v - pip))} onPlus={() => setTp(v => round(v + pip))} est={tpEst} theme={theme} inst={inst} /> setSl(v => round(v - pip))} onPlus={() => setSl(v => round(v + pip))} est={slEst} theme={theme} inst={inst} />
{/* footer — VWAP · Amount · Margin */}
{[ [t('ticket.estVwap'), fmtPrice(execPrice, inst.digits)], q2u > 0 ? [t('ticket.usdValue'), fmtUSD(valueUSD, 0)] // the quote currency is a canonical token — interpolated, never translated : [t('ticket.quoteAmount', { quote: inst.quote || '' }), fmtPlain(notionalQuote)], [t('ticket.marginReq'), margin > 0 ? fmtUSD(margin) : '—'], ].map(([label, val], i) => ( {i > 0 &&
}
{label}
{val}
))}
{/* P6: the ticket says who filled it in, immediately above the button — the last thing read before the thumb lands. */} {agentFill && (
{t('agent.prefill.banner.title', { name: agentFill.by })} { e.stopPropagation(); setAgentFill(null); uiClearPrefill(); }} style={{ marginInlineStart: 'auto', color: theme.textTer, fontSize: 11, textDecoration: 'underline', cursor: 'pointer' }} >{t('agent.prefill.banner.dismiss')}
{t('agent.prefill.banner.body')}
)} {/* single confirm — side already chosen on previous screen */}
phase === 'form' && submit()} style={{ padding: '16px', borderRadius: 14, background: isBuy ? theme.buy : theme.sell, color: '#fff', textAlign: 'center', fontWeight: 700, fontSize: 16, letterSpacing: -0.2, // Outlined while agent-filled, so it does not look like the // button on a ticket the human composed a moment ago. outline: agentFill ? `2px solid ${theme.accent}` : undefined, outlineOffset: agentFill ? 2 : undefined, cursor: phase === 'form' ? 'pointer' : 'default', opacity: phase === 'confirming' ? 0.7 : 1, boxShadow: `0 8px 20px ${(isBuy ? theme.buy : theme.sell)}55`, }} > {phase === 'confirming' ? t('ticket.placing') : type === 'Market' ? t('ticket.confirmMarket', { side: t(isBuy ? 'common.side.buy' : 'common.side.sell'), vol: vol.toFixed(2), symbol: sym }) : t('ticket.placePending', { side: t(isBuy ? 'common.side.buy' : 'common.side.sell'), kind: t('order.kind.' + type.toLowerCase()) })}
)}
); } function PriceHeroVol({ theme, vol, setVol, adjVol, inst, big, units }) { const step = inst.volStep || 0.01; // `base` is the asset the notional is denominated in — a canonical ticker // (EUR, BTC), so it is interpolated into the sub-line, never translated. // Only the generic fallback is a real word, and that one is a bundle lookup. const base = inst.cat === 'FX' ? inst.sym.slice(0,3) : (inst.cat === 'Crypto' ? inst.sym.replace('USD','') : t('common.units')); const sub = t('ticket.volSub', { units: (vol * units).toLocaleString(), asset: base }); return (
{t('common.volume')}
adjVol(-step)}>− adjVol(step)}>+
{sub}
); } function TpSlField({ kind, on, setOn, price, setPrice, onMinus, onPlus, est, theme, inst }) { const isTp = kind === 'tp'; const col = isTp ? theme.buy : theme.sell; const label = isTp ? t('common.takeProfit') : t('common.stopLoss'); if (!on) { // whole-string key: the leading "+" is part of the call to action, not a // fragment glued onto a separately translated label. return (
setOn(true)} style={{ flex: 1, padding: '11px', borderRadius: 12, background: theme.bgCard, border: `1px dashed ${col}66`, textAlign: 'center', color: col, fontSize: 13, fontWeight: 600, cursor: 'pointer', }}>{isTp ? t('ticket.addTakeProfit') : t('ticket.addStopLoss')}
); } return (
{label}
setOn(false)} style={{ width: 34, height: 20, borderRadius: 10, background: col, position: 'relative', cursor: 'pointer' }}> {/* Knob parked at the "on" end of the track. Logical, so the switch mirrors with everything else — a knob at the LEADING edge reads as off, which is the opposite of what this control means. */}
+
{bidiIsolate((est >= 0 ? '+' : '−') + '$' + Math.abs(est).toLocaleString('en-US', { maximumFractionDigits: 0 }))}
); } Object.assign(window, { TradeTicket, TpSlField, EditablePrice });