// 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 (
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 (