SPB Git

Repository Public

LiveBits.tsx 10.5 KB 120 lines tsx

1/**2 * Small pieces shared by the live screens. The most important one is the MODE BAND: which money the selected account moves is3 * written in words, with its own icon and its own pattern, on a band that is always on screen and cannot be dismissed —4 * « SIMULATION », « TESTNET — argent fictif », « RÉEL — argent réel ». Colour only repeats what the words say.5 */6import { useContext, useEffect, useState, type CSSProperties, type ReactNode } from 'react';7import { I18n, useT, type Key } from '../i18n';8import { Icon, Notice } from '../ui';9import { RISK_CODES } from './api';10import { Row } from './bits';11import { fmt, priceDp } from './dec';12import { clock, inFlight, remainingMs, type LiveErr, type LiveOrder, type LiveState, type Tone, type VenueOrder } from './live';13import type { Instrument } from './types';1415// fixed colours (not theme variables): the band must read the same in the light and the dark theme16const BAND: Record<Tone, { cls: string; style?: CSSProperties; icon: string }> = {17  paper: { cls: 'border border-blue/35 bg-blue/8 text-blue', icon: 'eye' },18  testnet: { cls: 'text-white', style: { background: 'repeating-linear-gradient(135deg, #5b34c9 0 14px, #6f49dc 14px 28px)' }, icon: 'book' },19  mainnet: { cls: 'text-white', style: { background: '#b3122b' }, icon: 'warn' },20};21export const toneWord = (tone: Tone): Key => (tone === 'paper' ? 'flToneSim' : tone === 'testnet' ? 'flToneTestnet' : 'flToneReal');2223/** always visible, never dismissible: there is no close control on purpose */24export function ModeBand({ tone, label, compact, className = '' }: { tone: Tone; label?: string; /** one line: the words and the account, without the sentence (the terminal's sticky band) */ compact?: boolean; className?: string }) {25  const t = useT(); const b = BAND[tone];26  return <div role="status" data-testid="mode-band" data-tone={tone} style={b.style} className={`rounded-xl px-3.5 py-2 leading-tight ${b.cls} ${className}`}>27    <div className="flex min-h-6 items-center gap-2"><Icon n={b.icon} className="h-4 w-4 shrink-0" /><span className="shrink-0 whitespace-nowrap text-[13px] font-extrabold uppercase tracking-[0.07em]">{t(toneWord(tone))}</span>28      {label ? <span className="min-w-0 flex-1 truncate text-right text-[12.5px] font-semibold">{label}</span> : null}</div>29    {!compact && <p className="mt-0.5 text-[12.5px] font-medium opacity-95">{t(tone === 'paper' ? 'flBandSim' : tone === 'testnet' ? 'flBandTestnet' : 'flBandReal')}</p>}</div>;30}31/** the same statement as a tag, beside a title or inside a sheet */32export function ModeTag({ tone, className = '' }: { tone: Tone; className?: string }) {33  const t = useT(); const b = BAND[tone];34  return <span data-testid="mode-tag" data-tone={tone} style={b.style} className={`inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10.5px] font-extrabold uppercase tracking-[0.06em] ${b.cls} ${className}`}>35    <Icon n={b.icon} className="h-3 w-3" />{t(toneWord(tone))}</span>;36}37export function SourceTag({ source = 'venue' }: { source?: 'venue' | 'local' | 'estimate' }) {38  const t = useT();39  return <span className="inline-flex items-center rounded border border-line px-1 py-px text-[10px] font-semibold uppercase tracking-wide text-mute" title={t(source === 'venue' ? 'flSrcVenueTitle' : source === 'local' ? 'flSrcLocalTitle' : 'flSrcEstimateTitle')}>{t(source === 'venue' ? 'flSrcVenue' : source === 'local' ? 'flSrcLocal' : 'estimate')}</span>;40}4142/** our sentence for the code, then the server's own words */43export function LiveErrNotice({ err, tone = 'danger', children }: { err: LiveErr; tone?: 'danger' | 'warn'; children?: ReactNode }) {44  const t = useT();45  const risk = RISK_CODES.has(err.code) && err.status === 422 && err.key === 'other';46  const text = risk ? t(`rk_${err.code}` as Key) : t(`flE_${err.key}` as Key);47  return <Notice tone={tone}><div data-testid="live-error" data-code={err.code}>48    {risk && <b className="font-semibold">{t('fxRefused')} </b>}{text}49    {err.detail && <span className="mt-1 block break-words font-mono text-[11.5px] text-mute">{err.code}{err.status ? ` (${err.status})` : ''} — {err.detail}</span>}{children}</div></Notice>;50}5152const ST_ICON: Record<string, string> = { open: 'check', filled: 'check', partially_filled: 'warn', rejected: 'x', cancelled: 'x', unknown: 'clock', submitted: 'clock', signed: 'clock', authorized: 'clock', risk_checked: 'key', proposed: 'key' };53const ST_CLS: Record<string, string> = { open: 'text-emerald bg-emerald/10', filled: 'text-emerald bg-emerald/10', partially_filled: 'text-warn bg-warn/12', rejected: 'text-danger bg-danger/10', cancelled: 'text-mute bg-elev', unknown: 'text-warn bg-warn/12' };54/** a live order's state: icon + word, never colour alone */55export function LiveStatePill({ state }: { state: LiveState }) {56  const t = useT();57  return <span data-state={state} className={`inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2 py-0.5 text-[11.5px] font-semibold ${ST_CLS[state] ?? (inFlight(state) ? ST_CLS.unknown : 'text-blue bg-blue/10')}`}><Icon n={ST_ICON[state] ?? 'clock'} className="h-3 w-3" />{t(`flSt_${state}` as Key)}</span>;58}59/** what happened to an order, in one sentence, from the server's state */60export function OrderOutcome({ order, instrument }: { order: LiveOrder; instrument?: Instrument }) {61  const t = useT(); const { locale } = useContext(I18n);62  const base = instrument?.base ?? order.instrument.slice(order.instrument.indexOf(':') + 1);63  const px = (v: string | null) => (v ? fmt(v, { dp: priceDp(instrument?.tickSize, v), locale }) : '—'), sz = (v: string) => fmt(v, { locale, trim: true });64  const tone = order.state === 'rejected' ? 'danger' : order.state === 'open' || order.state === 'filled' ? 'ok' : 'warn';65  return <Notice tone={tone}><div data-testid="live-outcome" data-state={order.state} aria-live="polite">66    <div className="mb-1 flex flex-wrap items-center gap-2"><LiveStatePill state={order.state} /><SourceTag source={order.source} /></div>67    {t(`flOut_${order.state}${order.state === 'partially_filled' && order.final ? '_final' : ''}` as Key, { filled: sz(order.filled), size: sz(order.size), base, price: px(order.avgFillPrice ?? order.price) })}68    {order.reason && <span className="mt-1 block break-words font-mono text-[11.5px] text-mute">{order.state === 'rejected' ? `${t('flVenueSays')} ` : ''}{order.reason}</span>}</div></Notice>;69}7071/** EXACTLY the canonical order the passkey signs — every field, as sent to the venue */72export function CanonicalRows({ c, instrument }: { c: VenueOrder; instrument?: Instrument }) {73  const t = useT(); const { locale } = useContext(I18n);74  const px = (v: string) => fmt(v, { dp: Math.max(priceDp(instrument?.tickSize, v), 0), locale });75  const base = instrument?.base ?? c.instrument.slice(c.instrument.indexOf(':') + 1), market = c.type === 'limit' && c.tif === 'ioc';76  return <div data-testid="canonical">77    <Row label={t('flCInstrument')} strong>{c.instrument}</Row>78    <Row label={t('flCSide')} strong><span className={c.side === 'buy' ? 'text-emerald' : 'text-danger'}>{t(c.side === 'buy' ? 'flCBuy' : 'flCSell')}</span></Row>79    <Row label={t('flCType')}>{t(`fxType_${c.type}` as Key)}{market ? <span className="block text-[11.5px] font-normal text-mute">{t('flCMarketNote')}</span> : null}</Row>80    <Row label={t('flCSize')} strong>{fmt(c.size, { locale, trim: true })} {base}</Row>81    <Row label={t(market || c.type.endsWith('_market') ? 'flCWorstPrice' : 'flCPrice')} strong>{px(c.price)}</Row>82    <Row label={t('flCTrigger')}>{c.triggerPrice ? px(c.triggerPrice) : t('flCNone')}</Row>83    <Row label={t('flCReduceOnly')}>{t(c.reduceOnly ? 'flYes' : 'flNo')}</Row>84    <Row label={t('flCTif')}>{t(`flTif_${c.tif}` as Key)}</Row>85    <Row label={t('flCNetwork')}>{c.network}</Row>86    <Row label={t('flCClientId')}><span className="break-all font-mono text-[11px]">{c.clientId}</span></Row>87    <Row label={t('flCNonce')}><span className="font-mono text-[11px]">{c.nonce}</span></Row>88  </div>;89}9091/** time left to approve; calls `onExpire` once when it reaches zero */92export function Countdown({ expiresAt, skewMs, ttlMs = 90_000, onExpire }: { expiresAt: number; skewMs: number; ttlMs?: number; onExpire: () => void }) {93  const t = useT();94  const [left, setLeft] = useState(() => remainingMs(expiresAt, Date.now(), skewMs));95  useEffect(() => {96    let done = false;97    const tick = () => { const l = remainingMs(expiresAt, Date.now(), skewMs); setLeft(l); if (l <= 0 && !done) { done = true; onExpire(); } };98    tick(); const h = setInterval(tick, 250); return () => clearInterval(h);99  }, [expiresAt, skewMs]);   // eslint-disable-line react-hooks/exhaustive-deps100  const low = left < 15_000;101  return <div data-testid="countdown" role="timer" aria-label={t('flExpiresIn', { time: clock(left) })}>102    <div className={`flex items-center justify-between text-[12.5px] font-semibold ${low ? 'text-danger' : 'text-ink'}`}><span className="inline-flex items-center gap-1.5"><Icon n="clock" className="h-3.5 w-3.5" />{t('flExpiresIn', { time: clock(left) })}</span></div>103    <div className="mt-1 h-1.5 overflow-hidden rounded-full bg-elev"><div className={`h-full rounded-full transition-[width] duration-200 ${low ? 'bg-danger' : 'bg-blue'}`} style={{ width: `${Math.max(0, Math.min(100, (left / ttlMs) * 100))}%` }} /></div></div>;104}105106/** label + explanation list item */107export function Bullet({ icon = 'check', tone = 'text-mute', children }: { icon?: string; tone?: string; children: ReactNode }) {108  return <li className="flex gap-2.5"><Icon n={icon} className={`mt-0.5 h-4 w-4 shrink-0 ${tone}`} /><span className="min-w-0">{children}</span></li>;109}110111/** the terminal's account switcher: paper AND venue accounts, each named with the money it moves */112export function AccountSwitch({ value, choices, onChange }: { value: string; choices: { id: string; label: string; tone: Tone }[]; onChange: (id: string) => void }) {113  const t = useT();114  const cur = choices.find((c) => c.id === value);115  if (choices.length <= 1) return <div className="flex items-center gap-2 text-[13px] font-semibold">{cur && <ModeTag tone={cur.tone} />}<span className="truncate">{cur?.label ?? '—'}</span></div>;116  return <label className="block text-[12px] text-mute">{t('fxAccount')}117    <select aria-label={t('fxAccount')} data-testid="account-switch" value={value} onChange={(e) => onChange(e.target.value)} className="mt-1 min-h-11 w-full rounded-xl border border-line bg-surface px-3 text-[14px] text-ink">118      {choices.map((c) => <option key={c.id} value={c.id}>{t(toneWord(c.tone))} — {c.label}</option>)}</select></label>;119}120
Files main
Menu

Theme