);
}
// 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 (
{/* 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())}
)}
{/* 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(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. */}