Repository Public
1import { useContext, useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';2import { createPortal } from 'react-dom';3import { I18n, useT, type Key } from '../i18n';4import { Icon } from '../ui';5import { D, dec, fmt, usd } from './dec';6import type { VenueId } from './types';78/** viewport test without rendering both layouts: one tree on a phone, another on a desktop */9export function useMedia(query: string): boolean {10 return useSyncExternalStore((f) => { const m = matchMedia(query); m.addEventListener('change', f); return () => m.removeEventListener('change', f); }, () => matchMedia(query).matches);11}12export const useLocalState = <T,>(key: string, initial: T): [T, (v: T) => void] => {13 const [v, setV] = useState<T>(() => { try { const s = localStorage.getItem(key); return s === null ? initial : (JSON.parse(s) as T); } catch { return initial; } });14 return [v, (n: T) => { setV(n); try { localStorage.setItem(key, JSON.stringify(n)); } catch { /* private mode */ } }];15};1617/** every money-like number of the simulated account carries this mark */18export function PaperTag({ className = '' }: { className?: string }) {19 const t = useT();20 return <span className={`inline-flex shrink-0 items-center rounded-md border border-blue/35 bg-blue/8 px-1.5 py-px text-[10.5px] font-bold uppercase tracking-[0.06em] text-blue ${className}`} title={t('fxPaperTitle')}>{t('fxPaper')}</span>;21}22export const VENUE_NAME: Record<VenueId, string> = { hyperliquid: 'Hyperliquid', dydx: 'dYdX v4', gmx: 'GMX v2' };23const VENUE_DOT: Record<VenueId, string> = { hyperliquid: '#1f9e8f', dydx: '#6966ff', gmx: '#2d6be0' };24export function VenueBadge({ venue, className = '' }: { venue: VenueId; className?: string }) {25 return <span className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface px-2 py-0.5 text-[11.5px] font-semibold text-ink ${className}`}>26 <span className="h-2 w-2 rounded-full" style={{ background: VENUE_DOT[venue] ?? '#667085' }} aria-hidden="true" />{VENUE_NAME[venue] ?? venue}</span>;27}2829/** Long / short: a word and an arrow, the colour only repeats them */30export function SideTag({ side }: { side: 'long' | 'short' | 'buy' | 'sell' }) {31 const t = useT(); const up = side === 'long' || side === 'buy';32 return <span className={`inline-flex items-center gap-1 text-[12.5px] font-semibold ${up ? 'text-emerald' : 'text-danger'}`}>33 <svg viewBox="0 0 12 12" className="h-2.5 w-2.5" aria-hidden="true"><path d={up ? 'M6 2l4.5 7h-9z' : 'M6 10L1.5 3h9z'} fill="currentColor" /></svg>{t(`fxSide_${side}` as Key)}</span>;34}35/** signed amount: '+' or '−' is always written, the colour only repeats it */36export function Pnl({ value, locale, unit = 'usd', dp = 2 }: { value: string | null | undefined; locale: string; unit?: 'usd' | 'plain'; dp?: number }) {37 const d = dec(value);38 if (!d) return <span className="text-mute">—</span>;39 const s = D.sign(D.div(d, D.one, dp, 'half_even'));40 return <span className={s > 0 ? 'text-emerald' : s < 0 ? 'text-danger' : 'text-ink'}>{unit === 'usd' ? usd(d, locale, true) : fmt(d, { dp, locale, sign: true })}</span>;41}4243/** segmented control; `tabs` makes it a tablist */44export function Seg<T extends string>({ value, options, onChange, label, tabs, size = 'md', className = '' }: { value: T; options: { id: T; label: ReactNode; aria?: string }[]; onChange: (v: T) => void; label: string; tabs?: boolean; size?: 'sm' | 'md'; className?: string }) {45 return <div role={tabs ? 'tablist' : 'radiogroup'} aria-label={label} className={`no-scrollbar flex gap-1 overflow-x-auto rounded-2xl bg-elev p-1 ${className}`}>{options.map((o) => { const on = value === o.id; return (46 <button key={o.id} type="button" role={tabs ? 'tab' : 'radio'} aria-selected={tabs ? on : undefined} aria-checked={tabs ? undefined : on} aria-label={o.aria} onClick={() => onChange(o.id)}47 className={`min-h-11 flex-[1_0_auto] whitespace-nowrap rounded-xl px-3 font-semibold transition ${size === 'sm' ? 'min-w-11 text-[13px]' : 'text-[14px]'} ${on ? 'bg-surface text-ink shadow-[0_1px_2px_rgba(15,23,42,0.12)]' : 'text-mute hover:text-ink'}`}>{o.label}</button>); })}</div>;48}49export function Stat({ label, children, className = '', wrap }: { label: ReactNode; children: ReactNode; className?: string; wrap?: boolean }) {50 return <div className={`min-w-0 ${className}`}><div className="truncate text-[11.5px] text-mute">{label}</div><div className={`text-[13.5px] font-medium ${wrap ? 'whitespace-nowrap' : 'truncate'}`}>{children}</div></div>;51}52export function Row({ label, children, strong }: { label: ReactNode; children: ReactNode; strong?: boolean }) {53 return <div className="flex items-baseline justify-between gap-3 py-1 text-[13.5px]"><span className="text-mute">{label}</span><span className={`text-right ${strong ? 'font-semibold' : 'font-medium'}`}>{children}</span></div>;54}5556/** bottom sheet on a phone, centred dialog on a desktop; Escape closes, focus stays inside */57export function Sheet({ title, onClose, children, wide }: { title: string; onClose: () => void; children: ReactNode; wide?: boolean }) {58 const t = useT(); const box = useRef<HTMLDivElement>(null);59 // the second click of a double click on the button that opened the sheet lands on the backdrop: it must not close it60 const openedAt = useRef(Date.now());61 useEffect(() => {62 const prev = document.activeElement as HTMLElement | null;63 box.current?.focus();64 const key = (e: KeyboardEvent) => {65 if (e.key === 'Escape') { e.stopPropagation(); onClose(); return; }66 if (e.key !== 'Tab' || !box.current) return;67 const f = [...box.current.querySelectorAll<HTMLElement>('button:not([disabled]),input:not([disabled]),select,textarea,[tabindex="0"]')];68 if (!f.length) return;69 const first = f[0]!, last = f[f.length - 1]!;70 if (e.shiftKey && (document.activeElement === first || document.activeElement === box.current)) { e.preventDefault(); last.focus(); }71 else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }72 };73 document.addEventListener('keydown', key);74 const o = document.body.style.overflow; document.body.style.overflow = 'hidden';75 return () => { document.removeEventListener('keydown', key); document.body.style.overflow = o; prev?.focus?.(); };76 }, [onClose]);77 // rendered under <body>: <main> animates with a transform, which would trap a fixed layer under the bottom navigation78 return createPortal(<div className="fixed inset-0 z-40 flex items-end justify-center bg-[rgba(8,11,18,0.45)] sm:items-center sm:p-6" onMouseDown={(e) => { if (e.target === e.currentTarget && Date.now() - openedAt.current > 500) onClose(); }}>79 <div ref={box} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1} className={`safe-b flex max-h-[92dvh] w-full flex-col rounded-t-[26px] bg-surface shadow-2xl outline-none sm:rounded-[26px] ${wide ? 'sm:max-w-2xl' : 'sm:max-w-md'}`}>80 <div className="flex items-center justify-between gap-3 px-5 pb-2 pt-4"><h2 className="text-[17px] font-semibold tracking-tight">{title}</h2>81 <button type="button" onClick={onClose} aria-label={t('fxClose')} className="grid h-11 w-11 shrink-0 place-items-center rounded-full text-mute hover:bg-elev"><Icon n="x" /></button></div>82 <div className="min-h-0 flex-1 overflow-y-auto px-5 pb-5">{children}</div>83 </div></div>, document.body);84}8586export interface Col<T> { id: string; label: ReactNode; cell: (row: T) => ReactNode; num?: boolean; /** on a phone: shown in the card's title line instead of the grid */ head?: boolean; /** on a phone: takes the whole width of the card */ full?: boolean }87/** a real table on wide screens, one card per row on a phone (never both in the DOM) */88export function DataTable<T>({ cols, rows, rowKey, empty, label }: { cols: Col<T>[]; rows: T[]; rowKey: (r: T) => string; empty: string; label: string }) {89 const wide = useMedia('(min-width: 768px)');90 if (!rows.length) return <p className="py-8 text-center text-[13.5px] text-mute">{empty}</p>;91 if (wide) return <div className="overflow-x-auto"><table className="w-full min-w-max border-separate border-spacing-0 text-[13px]" aria-label={label}>92 <thead><tr>{cols.map((c) => <th key={c.id} scope="col" className={`whitespace-nowrap border-b border-line px-2.5 py-2 text-[11.5px] font-medium text-mute ${c.num ? 'text-right' : 'text-left'}`}>{c.label}</th>)}</tr></thead>93 <tbody>{rows.map((r) => <tr key={rowKey(r)}>{cols.map((c) => <td key={c.id} className={`whitespace-nowrap border-b border-line/70 px-2.5 py-2 align-middle ${c.num ? 'text-right' : 'text-left'}`}>{c.cell(r)}</td>)}</tr>)}</tbody></table></div>;94 return <ul className="space-y-2.5" aria-label={label}>{rows.map((r) => <li key={rowKey(r)} className="rounded-2xl border border-line p-3.5">95 <div className="mb-2 flex flex-wrap items-center gap-x-3 gap-y-1">{cols.filter((c) => c.head).map((c) => <span key={c.id} className="text-[14px] font-semibold">{c.cell(r)}</span>)}</div>96 <dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[13px]">{cols.filter((c) => !c.head).map((c) => <div key={c.id} className={c.label === '' || c.full ? 'col-span-2' : 'min-w-0'}>{c.label !== '' && <dt className="text-[11.5px] text-mute">{c.label}</dt>}<dd className="min-w-0 break-words font-medium">{c.cell(r)}</dd></div>)}</dl></li>)}</ul>;97}98const p2 = (n: number) => String(n).padStart(2, '0');99/** 24-hour clock « 14:05:09 »; with the date: « 10 nov. 14:05 » */100export const timeOf = (ts: number, locale: string, withDate = false) => { const d = new Date(ts); return withDate ? `${d.toLocaleDateString(locale === 'fr' ? 'fr-CA' : 'en-CA', { month: 'short', day: 'numeric' })} ${p2(d.getHours())}:${p2(d.getMinutes())}` : `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`; };101/** « 14 s », « 3 min », « 2 h » */102export const ageText = (ms: number | null): string => (ms === null ? '—' : ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))} s` : ms < 3_600_000 ? `${Math.round(ms / 60_000)} min` : `${Math.round(ms / 3_600_000)} h`);103104/** A sentence written by the server, in English. Where the interface already says the same thing in the owner's language105 * (every use of this component follows such a sentence), the English original is only shown in the English interface. */106export function ServerSays({ text, className, mono }: { text: string; className: string; mono?: boolean }) {107 const t = useT(); const { locale } = useContext(I18n);108 if (locale !== 'en') return null;109 return <span className={`block ${className}`}>{t('fxServerSays')} {mono ? <span className="font-mono text-[11px]">{text}</span> : text}</span>;110}111