// Markets tab — list of instruments grouped by category with live prices function PriceCell({ value, digits, flash, tint, theme, align = 'right' }) { // flash: 'up' | 'down' | null — brief background flash on tick const { big, small } = splitPrice(value, digits); const bg = flash === 'up' ? theme.buyBg : flash === 'down' ? theme.sellBg : 'transparent'; const color = tint === 'buy' ? theme.buy : tint === 'sell' ? theme.sell : theme.text; 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).
{big} {small}
); } function SparkLine({ data, color, width = 56, height = 20 }) { if (!data || data.length < 2) return null; const min = Math.min(...data), max = Math.max(...data); const range = max - min || 1; const pts = data.map((v, i) => { const x = (i / (data.length - 1)) * width; const y = height - ((v - min) / range) * height; return `${x.toFixed(1)},${y.toFixed(1)}`; }).join(' '); return ( ); } function SymbolIcon({ sym, cat, theme }) { // stylized 2-letter badge, color by category const palette = { FX: ['#3B82F6', '#60A5FA'], Metals: ['#D97706', '#F59E0B'], Energy: ['#DC2626', '#F87171'], Indices: ['#7C3AED', '#A78BFA'], Crypto: ['#F7931A', '#FFB74D'], }; const [a, b] = palette[cat] || ['#64748B', '#94A3B8']; // 2 letters: first 2 of symbol if not clearly a pair let label; if (sym.length === 6 && sym.match(/^[A-Z]{6}$/)) { label = sym.slice(0, 3) + '/' + sym.slice(3); } else { label = sym; } return (
5 ? 8.5 : 10, fontWeight: 700, letterSpacing: 0.2, boxShadow: `0 2px 8px ${a}40`, }}> {label}
); } const MKT_CAT_COLOR = { FX: '#3B82F6', Metals: '#E0A21A', Energy: '#E2563B', Indices: '#8B5CF6', Crypto: '#F7931A', }; function PxLive({ value, digits, pipDigits, flash, theme, align, width, size = 19 }) { const { base, pips, frac } = splitPip(value, digits, pipDigits); const bg = flash === 'up' ? theme.buyBg : flash === 'down' ? theme.sellBg : 'transparent'; const sigSize = size; const baseSize = Math.round(size * 0.75); // 75% of significant const fracSize = Math.round(size * 0.50); // 50% of significant return (
{/* outer span only for the tick-flash background */} {/* BASE — bottom-aligned with pips (shared baseline) */} {base} {/* SIGNIFICANT PIPS — visual anchor, tallest */} {pips} {/* FRACTIONAL — top edge flush with cap-height of pips */} {frac && ( {frac} )}
); } function MarketRow({ inst, state, theme, onTap, density, rowSeparator = true }) { const { bid, ask, flashBid, flashAsk } = state; const pct = inst.pctDay + (bid - inst.bid) / inst.bid * 100; const isUp = pct >= 0; const c = MKT_CAT_COLOR[inst.cat] || '#64748B'; const pad = density === 'compact' ? '7px 16px' : density === 'spacious' ? '17px 16px' : '13px 16px'; const sym = inst.sym.length === 6 && inst.sym.match(/^[A-Z]{6}$/) ? inst.sym.slice(0, 3) + '/' + inst.sym.slice(3) : inst.sym; const spStr = Number.isInteger(inst.spread) ? String(inst.spread) : inst.spread.toFixed(1); return (
e.currentTarget.style.background = theme.bgSubtle} onMouseUp={e => e.currentTarget.style.background = 'transparent'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} > {/* category dot */}
{/* symbol + % — symbol fixed width so pct column stays pinned */}
{sym} {Math.abs(pct).toFixed(2)}%
{/* bid · spread · ask — fixed columns */}
{spStr}
); } function CategoryChips({ active, onChange, theme }) { const favActive = active === 'Favorites'; const scrollRef = React.useRef(null); const drag = React.useRef(null); const justDragged = React.useRef(false); // Drag-to-scroll (mouse) + stopPropagation so the tab-swipe handler // doesn't hijack horizontal gestures that start on the chips. const onDown = (e) => { e.stopPropagation(); drag.current = { x: e.clientX, sl: scrollRef.current.scrollLeft, moved: false }; }; const onMove = (e) => { if (!drag.current) return; const dx = e.clientX - drag.current.x; if (Math.abs(dx) > 3) drag.current.moved = true; scrollRef.current.scrollLeft = drag.current.sl - dx; }; const onUp = () => { if (drag.current && drag.current.moved) justDragged.current = true; drag.current = null; }; return (
e.stopPropagation()} onClickCapture={(e) => { if (justDragged.current) { e.stopPropagation(); e.preventDefault(); justDragged.current = false; } }} style={{ display: 'flex', gap: 8, padding: '4px 16px 12px', overflowX: 'auto', scrollbarWidth: 'none', touchAction: 'pan-x', WebkitOverflowScrolling: 'touch', cursor: 'grab', userSelect: 'none', }} > {/* Favorites star filter */}
onChange(favActive ? 'All' : 'Favorites')} style={{ width: 34, height: 34, borderRadius: 100, flexShrink: 0, background: favActive ? 'rgba(245,182,38,0.16)' : theme.bgElevated, border: favActive ? '0.5px solid rgba(245,182,38,0.45)' : `0.5px solid ${theme.border}`, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 150ms', }} >
{['All', ...CATEGORIES].map(cat => { const isActive = active === cat; return (
onChange(cat)} style={{ padding: '7px 14px', borderRadius: 100, background: isActive ? theme.text : theme.bgElevated, color: isActive ? theme.bg : theme.text, fontSize: 13, fontWeight: 600, whiteSpace: 'nowrap', cursor: 'pointer', border: isActive ? 'none' : `0.5px solid ${theme.border}`, letterSpacing: -0.1, display: 'flex', alignItems: 'center', gap: 6, }} > {cat !== 'All' && CAT_ICONS[cat](isActive ? theme.bg : theme.textSec)} {/* `cat` stays the canonical English value — it is the filter comparison, the CAT_ICONS key and the `inst.cat` match. Only the rendered label is localized. */} {t('common.category.' + cat.toLowerCase())}
); })}
); } // Favourites empty-state body. The ★ sits mid-sentence AND carries its own // styling, so it is a single key with a {star} placeholder that is split at // render time rather than substituted: a t() parameter stringifies its value, // which would flatten the gold into plain text, and three separate keys // would nail the sentence to English word order. Only mounted when the list is // empty, so the split allocates nothing on the per-tick path. function FavHintBody() { const parts = t('markets.empty.favorites.body').split('{star}'); return ( {parts[0]} {parts[1]} ); } function MarketsTab({ prices, theme, onTap, category, setCategory, density, rowSeparator, favorites, search, setSearch, showSearch, setShowSearch }) { const filtered = INSTRUMENTS.filter(i => { if (category === 'Favorites') { if (!favorites.has(i.sym)) return false; } else if (category !== 'All' && i.cat !== category) return false; if (search && !(`${i.sym} ${i.name}`.toLowerCase().includes(search.toLowerCase()))) return false; return true; }); // Group by category for All + Favorites; single group otherwise const grouped = category === 'All' || category === 'Favorites'; const groups = React.useMemo(() => { if (!grouped) return [[category, filtered]]; const out = {}; for (const i of filtered) { if (!out[i.cat]) out[i.cat] = []; out[i.cat].push(i); } return CATEGORIES.map(c => [c, out[c] || []]).filter(([, list]) => list.length > 0); }, [category, filtered, grouped]); return (
{showSearch && (
setSearch(e.target.value)} placeholder={t('markets.search.placeholder')} style={{ width: '100%', padding: '10px 14px', borderRadius: 12, background: theme.bgElevated, border: `0.5px solid ${theme.border}`, color: theme.text, fontSize: 15, outline: 'none', boxSizing: 'border-box', fontFamily: 'inherit', }} />
)} {/* Column header */}
{t('markets.col.symbolDay')}
{t('markets.col.bid')}
{t('markets.col.spread')}
{t('markets.col.ask')}
{groups.map(([cat, list]) => (
{grouped && (
{CAT_ICONS[cat](theme.textSec)} {t('common.category.' + cat.toLowerCase())} {list.length}
)}
{list.map(inst => ( onTap(inst)} /> ))}
))} {filtered.length === 0 && (
{category === 'Favorites' && !search ? (
{t('markets.empty.favorites.title')}
) : (
{t('markets.empty.noMatch', { query: search })}
)}
)}
); } Object.assign(window, { MarketsTab, SymbolIcon, PriceCell, SparkLine });