dev-api-ui 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/package.json +40 -0
- package/ui/ThemeProvider.tsx +16 -0
- package/ui/components/ArticlePage.tsx +673 -0
- package/ui/components/AuthPage.tsx +109 -0
- package/ui/components/Callout.tsx +79 -0
- package/ui/components/Chart.tsx +100 -0
- package/ui/components/CodeTabs.tsx +145 -0
- package/ui/components/ComparePage.tsx +364 -0
- package/ui/components/DashboardPage.tsx +773 -0
- package/ui/components/DocsNav.tsx +80 -0
- package/ui/components/Footer.tsx +136 -0
- package/ui/components/HubPage.tsx +529 -0
- package/ui/components/Modal.tsx +412 -0
- package/ui/components/Nav.tsx +162 -0
- package/ui/components/OnThisPage.tsx +56 -0
- package/ui/components/ParamsTable.tsx +86 -0
- package/ui/components/RangeBar.tsx +68 -0
- package/ui/components/SeriesChart.tsx +218 -0
- package/ui/components/SeriesPage.tsx +461 -0
- package/ui/components/StatusMark.tsx +48 -0
- package/ui/components/publictrades/ExternalLink.tsx +37 -0
- package/ui/components/publictrades/FactsCard.tsx +40 -0
- package/ui/components/publictrades/LedgerFooter.tsx +22 -0
- package/ui/components/publictrades/LedgerNav.tsx +78 -0
- package/ui/components/publictrades/Mark.tsx +40 -0
- package/ui/components/publictrades/SourceBlock.tsx +91 -0
- package/ui/components/publictrades/Tape.tsx +164 -0
- package/ui/components/publictrades/ThreeStamps.tsx +47 -0
- package/ui/components/publictrades/types.ts +19 -0
- package/ui/data/series.ts +851 -0
- package/ui/index.ts +50 -0
- package/ui/publictrades.ts +16 -0
- package/ui/themes.css +81 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, ChangeEvent } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import StatusMark from './StatusMark';
|
|
6
|
+
|
|
7
|
+
const A = '#1F8A5B';
|
|
8
|
+
const D = '#C2453B';
|
|
9
|
+
const W = '#B26B00';
|
|
10
|
+
|
|
11
|
+
// ── per-group static config ──────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
type GroupConfig = {
|
|
14
|
+
label: string;
|
|
15
|
+
eyebrow: string;
|
|
16
|
+
desc: string;
|
|
17
|
+
pairs: string;
|
|
18
|
+
coverage: string;
|
|
19
|
+
cadence: string;
|
|
20
|
+
filterPlaceholder: string;
|
|
21
|
+
allSeriesLabel: string;
|
|
22
|
+
totalCount: number;
|
|
23
|
+
noun: string; // unit noun: pairs / series / nodes
|
|
24
|
+
emptyHint: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const GROUP_CONFIG: Record<string, GroupConfig> = {
|
|
28
|
+
fx: {
|
|
29
|
+
label: 'fx',
|
|
30
|
+
eyebrow: 'Foreign exchange',
|
|
31
|
+
desc: 'Major, minor, cross, and emerging-market spot rates from ECB reference and EBS interbank quotes, reconciled tick by tick. Every pair is point-in-time and traces to its source feed.',
|
|
32
|
+
pairs: '168',
|
|
33
|
+
coverage: '1999',
|
|
34
|
+
cadence: 'tick cadence',
|
|
35
|
+
filterPlaceholder: 'Filter fx pairs…',
|
|
36
|
+
allSeriesLabel: 'All fx pairs',
|
|
37
|
+
totalCount: 168,
|
|
38
|
+
noun: 'pairs',
|
|
39
|
+
emptyHint: 'Try a currency code like EUR or JPY.',
|
|
40
|
+
},
|
|
41
|
+
metals: {
|
|
42
|
+
label: 'metals',
|
|
43
|
+
eyebrow: 'Precious metals',
|
|
44
|
+
desc: 'Spot and fix prices for gold, silver, platinum, and palladium from LBMA fixings and COMEX settlement, normalised to troy-ounce USD. Every record is timestamped and source-attributed.',
|
|
45
|
+
pairs: '12',
|
|
46
|
+
coverage: '1968',
|
|
47
|
+
cadence: '1 min cadence',
|
|
48
|
+
filterPlaceholder: 'Filter metals…',
|
|
49
|
+
allSeriesLabel: 'All metals series',
|
|
50
|
+
totalCount: 12,
|
|
51
|
+
noun: 'series',
|
|
52
|
+
emptyHint: 'Try a symbol like XAU or XAG.',
|
|
53
|
+
},
|
|
54
|
+
power: {
|
|
55
|
+
label: 'power',
|
|
56
|
+
eyebrow: 'Wholesale power',
|
|
57
|
+
desc: 'Real-time and day-ahead locational marginal prices from major US ISOs — PJM, CAISO, ERCOT, MISO, NYISO, and SPP — delivered in $/MWh with node, interval, and component detail.',
|
|
58
|
+
pairs: '94',
|
|
59
|
+
coverage: '2005',
|
|
60
|
+
cadence: '5 min cadence',
|
|
61
|
+
filterPlaceholder: 'Filter power nodes…',
|
|
62
|
+
allSeriesLabel: 'All power series',
|
|
63
|
+
totalCount: 94,
|
|
64
|
+
noun: 'nodes',
|
|
65
|
+
emptyHint: 'Try an ISO name like PJM or CAISO.',
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ── illustrative row data per group (from design file) ───────────────────────
|
|
70
|
+
|
|
71
|
+
type Row = { sym: string; name: string; last: string; delta: string; up: boolean; status: string; cat: string; slug: string };
|
|
72
|
+
|
|
73
|
+
const GROUP_ROWS: Record<string, Row[]> = {
|
|
74
|
+
fx: [
|
|
75
|
+
{ sym: 'EUR/USD', name: 'Euro / US Dollar', last: '1.08642', delta: '+0.31%', up: true, status: 'Live', cat: 'Majors', slug: 'eurusd' },
|
|
76
|
+
{ sym: 'GBP/USD', name: 'Sterling / US Dollar', last: '1.27310', delta: '+0.12%', up: true, status: 'Live', cat: 'Majors', slug: 'gbpusd' },
|
|
77
|
+
{ sym: 'USD/JPY', name: 'US Dollar / Yen', last: '157.21', delta: '-0.20%', up: false, status: 'Live', cat: 'Majors', slug: 'usdjpy' },
|
|
78
|
+
{ sym: 'USD/CHF', name: 'US Dollar / Franc', last: '0.89204', delta: '+0.05%', up: true, status: 'Live', cat: 'Majors', slug: 'usdchf' },
|
|
79
|
+
{ sym: 'USD/CAD', name: 'US Dollar / Canadian Dollar', last: '1.36110', delta: '-0.08%', up: false, status: 'Live', cat: 'Majors', slug: 'usdcad' },
|
|
80
|
+
{ sym: 'AUD/USD', name: 'Australian Dollar / US Dollar', last: '0.66420', delta: '+0.22%', up: true, status: 'Live', cat: 'Majors', slug: 'audusd' },
|
|
81
|
+
{ sym: 'EUR/GBP', name: 'Euro / Sterling', last: '0.85330', delta: '+0.18%', up: true, status: 'Live', cat: 'Crosses', slug: 'eurgbp' },
|
|
82
|
+
{ sym: 'EUR/JPY', name: 'Euro / Yen', last: '170.80', delta: '+0.09%', up: true, status: 'Live', cat: 'Crosses', slug: 'eurjpy' },
|
|
83
|
+
{ sym: 'GBP/JPY', name: 'Sterling / Yen', last: '200.15', delta: '-0.14%', up: false, status: 'Live', cat: 'Crosses', slug: 'gbpjpy' },
|
|
84
|
+
{ sym: 'USD/MXN', name: 'US Dollar / Mexican Peso', last: '18.4200', delta: '+0.40%', up: true, status: 'Delayed', cat: 'EM', slug: 'usdmxn' },
|
|
85
|
+
{ sym: 'USD/BRL', name: 'US Dollar / Brazilian Real', last: '5.3800', delta: '-0.55%', up: false, status: 'Delayed', cat: 'EM', slug: 'usdbrl' },
|
|
86
|
+
{ sym: 'USD/INR', name: 'US Dollar / Indian Rupee', last: '83.4500', delta: '+0.06%', up: true, status: 'Live', cat: 'EM', slug: 'usdinr' },
|
|
87
|
+
],
|
|
88
|
+
metals: [
|
|
89
|
+
{ sym: 'XAU/USD', name: 'Gold / US Dollar', last: '2,338.40', delta: '-0.62%', up: false, status: 'Delayed 15m', cat: 'Spot', slug: 'xauusd' },
|
|
90
|
+
{ sym: 'XAG/USD', name: 'Silver / US Dollar', last: '29.84', delta: '+0.44%', up: true, status: 'Delayed', cat: 'Spot', slug: 'xagusd' },
|
|
91
|
+
{ sym: 'XPT/USD', name: 'Platinum / US Dollar', last: '978.50', delta: '+1.02%', up: true, status: 'Delayed', cat: 'Spot', slug: 'xptusd' },
|
|
92
|
+
{ sym: 'XPD/USD', name: 'Palladium / US Dollar', last: '912.00', delta: '-0.18%', up: false, status: 'Delayed', cat: 'Spot', slug: 'xpdusd' },
|
|
93
|
+
{ sym: 'XAU/EUR', name: 'Gold / Euro', last: '2,153.90', delta: '-0.31%', up: false, status: 'Delayed', cat: 'Crosses', slug: 'xaueur' },
|
|
94
|
+
{ sym: 'XAU/GBP', name: 'Gold / Sterling', last: '1,838.20', delta: '-0.48%', up: false, status: 'Delayed', cat: 'Crosses', slug: 'xaugbp' },
|
|
95
|
+
],
|
|
96
|
+
power: [
|
|
97
|
+
{ sym: 'PJM-RT', name: 'PJM Real-Time LMP', last: '38.21', delta: '+4.10%', up: true, status: 'Degraded', cat: 'Real-Time', slug: 'pjm-rt' },
|
|
98
|
+
{ sym: 'CAISO-RT', name: 'CAISO SP15 real-time', last: '52.40', delta: '+7.22%', up: true, status: 'Live', cat: 'Real-Time', slug: 'caiso-rt' },
|
|
99
|
+
{ sym: 'ERCOT-RT', name: 'ERCOT Houston real-time', last: '29.95', delta: '-1.80%', up: false, status: 'Live', cat: 'Real-Time', slug: 'ercot-rt' },
|
|
100
|
+
{ sym: 'MISO-RT', name: 'MISO Indiana hub', last: '34.18', delta: '+2.50%', up: true, status: 'Degraded', cat: 'Real-Time', slug: 'miso-rt' },
|
|
101
|
+
{ sym: 'NYISO-RT', name: 'NYISO Zone J real-time', last: '45.80', delta: '+9.10%', up: true, status: 'Live', cat: 'Real-Time', slug: 'nyiso-rt' },
|
|
102
|
+
{ sym: 'SPP-RT', name: 'SPP South hub real-time', last: '27.40', delta: '-3.20%', up: false, status: 'Live', cat: 'Real-Time', slug: 'spp-rt' },
|
|
103
|
+
{ sym: 'PJM-DA', name: 'PJM Day-Ahead LMP', last: '36.80', delta: '-0.90%', up: false, status: 'Live', cat: 'Day-Ahead', slug: 'pjm-da' },
|
|
104
|
+
{ sym: 'CAISO-DA', name: 'CAISO SP15 day-ahead', last: '48.60', delta: '+1.40%', up: true, status: 'Live', cat: 'Day-Ahead', slug: 'caiso-da' },
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// ── per-group categories (leaf mode browse cards) ────────────────────────────
|
|
109
|
+
|
|
110
|
+
type Category = { name: string; count: string; desc: string };
|
|
111
|
+
|
|
112
|
+
const GROUP_CATEGORIES: Record<string, Category[]> = {
|
|
113
|
+
fx: [
|
|
114
|
+
{ name: 'Majors', count: '7 pairs', desc: 'The most-traded USD pairs — EUR, GBP, JPY, CHF, CAD, AUD, NZD.' },
|
|
115
|
+
{ name: 'Crosses', count: '21 pairs', desc: 'Non-USD majors like EUR/GBP and GBP/JPY.' },
|
|
116
|
+
{ name: 'Emerging', count: '64 pairs', desc: 'MXN, BRL, INR, ZAR and other EM crosses.' },
|
|
117
|
+
{ name: 'Exotics', count: '76 pairs', desc: 'Thin and frontier pairs, flagged for liquidity.' },
|
|
118
|
+
],
|
|
119
|
+
metals: [
|
|
120
|
+
{ name: 'Spot', count: '4 series', desc: 'LBMA/COMEX spot prices in USD per troy ounce.' },
|
|
121
|
+
{ name: 'Fixings', count: '4 series', desc: 'Official AM and PM fixings from LBMA.' },
|
|
122
|
+
{ name: 'Crosses', count: '4 series', desc: 'Precious metals quoted in non-USD currencies.' },
|
|
123
|
+
],
|
|
124
|
+
power: [
|
|
125
|
+
{ name: 'Real-Time', count: '48 nodes', desc: 'Sub-hourly real-time LMP at major ISO hubs.' },
|
|
126
|
+
{ name: 'Day-Ahead', count: '36 nodes', desc: 'Hour-ahead and day-ahead pricing across ISOs.' },
|
|
127
|
+
{ name: 'Ancillary', count: '10 series', desc: 'Regulation, spinning reserves, and capacity prices.' },
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
function statusColor(status: string): string {
|
|
134
|
+
if (status === 'Live') return A;
|
|
135
|
+
if (status.startsWith('Degraded')) return W;
|
|
136
|
+
if (status === 'Delayed' || status === 'Delayed 15m') return W;
|
|
137
|
+
return D; // Outage
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function deltaColor(up: boolean): string {
|
|
141
|
+
return up ? A : D;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── sub-components ───────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
// The shared table / list layout used in both list and search modes
|
|
147
|
+
function RowTable({ rows, group }: { rows: Row[]; group: string }) {
|
|
148
|
+
const slugFor = (r: Row) => r.slug;
|
|
149
|
+
return (
|
|
150
|
+
<div
|
|
151
|
+
style={{
|
|
152
|
+
background: 'var(--surface)',
|
|
153
|
+
border: '0.5px solid var(--hairline-2)',
|
|
154
|
+
borderRadius: 8,
|
|
155
|
+
overflow: 'hidden',
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
{/* column header */}
|
|
159
|
+
<div
|
|
160
|
+
style={{
|
|
161
|
+
display: 'grid',
|
|
162
|
+
gridTemplateColumns: '140px 1fr 120px 110px 110px 20px',
|
|
163
|
+
alignItems: 'center',
|
|
164
|
+
gap: 16,
|
|
165
|
+
padding: '9px 18px',
|
|
166
|
+
borderBottom: '0.5px solid var(--hairline-2)',
|
|
167
|
+
background: '#F4F4F1',
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
{['Symbol', 'Name', 'Last', 'Δ today', 'Status', ''].map((h, i) => (
|
|
171
|
+
<span
|
|
172
|
+
key={i}
|
|
173
|
+
style={{
|
|
174
|
+
fontSize: 10,
|
|
175
|
+
fontWeight: 600,
|
|
176
|
+
textTransform: 'uppercase',
|
|
177
|
+
letterSpacing: '0.07em',
|
|
178
|
+
color: 'var(--tertiary)',
|
|
179
|
+
textAlign: i === 2 || i === 3 ? 'right' : 'left',
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
{h}
|
|
183
|
+
</span>
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{rows.map((r, i) => (
|
|
188
|
+
<Link
|
|
189
|
+
key={r.sym}
|
|
190
|
+
href={`/${group}/${slugFor(r)}`}
|
|
191
|
+
style={{
|
|
192
|
+
display: 'grid',
|
|
193
|
+
gridTemplateColumns: '140px 1fr 120px 110px 110px 20px',
|
|
194
|
+
alignItems: 'center',
|
|
195
|
+
gap: 16,
|
|
196
|
+
padding: '12px 18px',
|
|
197
|
+
borderBottom: i < rows.length - 1 ? '0.5px solid var(--hairline)' : undefined,
|
|
198
|
+
fontSize: 13,
|
|
199
|
+
color: 'inherit',
|
|
200
|
+
textDecoration: 'none',
|
|
201
|
+
}}
|
|
202
|
+
>
|
|
203
|
+
<span style={{ fontFamily: 'var(--mono)', fontWeight: 600 }}>{r.sym}</span>
|
|
204
|
+
<span style={{ color: 'var(--secondary)' }}>{r.name}</span>
|
|
205
|
+
<span style={{ textAlign: 'right', fontWeight: 500 }}>{r.last}</span>
|
|
206
|
+
<span style={{ textAlign: 'right', color: deltaColor(r.up) }}>{r.delta}</span>
|
|
207
|
+
<span>
|
|
208
|
+
<StatusMark color={statusColor(r.status)} label={r.status} size={6} fontSize={12} />
|
|
209
|
+
</span>
|
|
210
|
+
<span style={{ textAlign: 'right', display: 'inline-flex', color: 'var(--accent)' }}>
|
|
211
|
+
{/* external link arrow */}
|
|
212
|
+
<svg viewBox="0 0 24 24" width={14} height={14} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
213
|
+
<path d="M13 5h6v6" /><path d="M19 5l-8 8" /><path d="M19 13v5a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5" />
|
|
214
|
+
</svg>
|
|
215
|
+
</span>
|
|
216
|
+
</Link>
|
|
217
|
+
))}
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── main component ────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
interface HubPageProps {
|
|
225
|
+
group: string;
|
|
226
|
+
/** Override the group description paragraph (§E3 — product-supplied copy). */
|
|
227
|
+
description?: string;
|
|
228
|
+
/** Override the headline count string (e.g. "168"). Shown in the meta-pills row. */
|
|
229
|
+
pairs?: string;
|
|
230
|
+
/** Override the eyebrow label (e.g. "Foreign exchange"). */
|
|
231
|
+
eyebrow?: string;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
type Mode = 'leaf' | 'list' | 'search';
|
|
235
|
+
|
|
236
|
+
export default function HubPage({ group, description, pairs: pairsOverride, eyebrow }: HubPageProps) {
|
|
237
|
+
const [mode, setMode] = useState<Mode>('leaf');
|
|
238
|
+
const [query, setQuery] = useState('');
|
|
239
|
+
|
|
240
|
+
const config = GROUP_CONFIG[group];
|
|
241
|
+
const resolvedDesc = description ?? config.desc;
|
|
242
|
+
const resolvedPairs = pairsOverride ?? config.pairs;
|
|
243
|
+
const resolvedEyebrow = eyebrow ?? config.eyebrow;
|
|
244
|
+
const allRows = GROUP_ROWS[group] ?? [];
|
|
245
|
+
const categories = GROUP_CATEGORIES[group] ?? [];
|
|
246
|
+
|
|
247
|
+
const q = query.trim().toUpperCase();
|
|
248
|
+
const filtered = q
|
|
249
|
+
? allRows.filter((r) => (r.sym + ' ' + r.name + ' ' + r.cat).toUpperCase().includes(q))
|
|
250
|
+
: allRows;
|
|
251
|
+
|
|
252
|
+
const popular = allRows.slice(0, 6);
|
|
253
|
+
const total = filtered.length;
|
|
254
|
+
const resultLabel = q
|
|
255
|
+
? `${total} ${total === 1 ? 'result' : 'results'}`
|
|
256
|
+
: `Showing ${total} of ${config.totalCount}`;
|
|
257
|
+
|
|
258
|
+
// When the user types, switch to search mode automatically
|
|
259
|
+
function handleQuery(e: ChangeEvent<HTMLInputElement>) {
|
|
260
|
+
const v = e.target.value;
|
|
261
|
+
setQuery(v);
|
|
262
|
+
if (v.trim()) setMode('search');
|
|
263
|
+
else setMode('leaf');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const modeDefs: { id: Mode; label: string }[] = [
|
|
267
|
+
{ id: 'leaf', label: 'Overview' },
|
|
268
|
+
{ id: 'list', label: 'All series' },
|
|
269
|
+
{ id: 'search', label: 'Search' },
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<main>
|
|
274
|
+
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '0 32px' }}>
|
|
275
|
+
|
|
276
|
+
{/* ── HEADER ───────────────────────────────────────────────────── */}
|
|
277
|
+
<section style={{ padding: '28px 0 24px', borderBottom: '0.5px solid var(--hairline)' }}>
|
|
278
|
+
{/* breadcrumb */}
|
|
279
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--tertiary)' }}>
|
|
280
|
+
<Link href="#" style={{ color: 'var(--secondary)' }}>Data</Link>
|
|
281
|
+
<span>/</span>
|
|
282
|
+
<span style={{ color: 'var(--ink)' }}>{group}</span>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
{/* eyebrow */}
|
|
286
|
+
<div style={{
|
|
287
|
+
display: 'inline-flex', alignItems: 'center', gap: 8,
|
|
288
|
+
fontSize: 12, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase',
|
|
289
|
+
color: 'var(--accent)', marginTop: 16,
|
|
290
|
+
}}>
|
|
291
|
+
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--accent)', display: 'inline-block' }} />
|
|
292
|
+
{resolvedEyebrow}
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
{/* H1 */}
|
|
296
|
+
<h1 style={{
|
|
297
|
+
fontFamily: 'var(--mono)', fontSize: 36, fontWeight: 600,
|
|
298
|
+
letterSpacing: '-0.02em', marginTop: 12, lineHeight: 1.05,
|
|
299
|
+
}}>
|
|
300
|
+
{group}{' '}
|
|
301
|
+
<span style={{ fontSize: 20, fontWeight: 500, color: 'var(--secondary)' }}>
|
|
302
|
+
{group === 'fx' ? 'spot rates' : group === 'metals' ? 'spot prices' : 'LMP series'}
|
|
303
|
+
</span>
|
|
304
|
+
</h1>
|
|
305
|
+
|
|
306
|
+
{/* desc */}
|
|
307
|
+
<p style={{ fontSize: 17, color: 'var(--secondary)', marginTop: 10, lineHeight: 1.55 }}>
|
|
308
|
+
{resolvedDesc}
|
|
309
|
+
</p>
|
|
310
|
+
|
|
311
|
+
{/* meta pills (plain inline tokens) */}
|
|
312
|
+
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap', marginTop: 16, fontSize: 13, color: 'var(--tertiary)' }}>
|
|
313
|
+
<span><span style={{ color: 'var(--ink)', fontWeight: 500 }}>{resolvedPairs}</span> {config.noun}</span>
|
|
314
|
+
<span>·</span>
|
|
315
|
+
<span>coverage since <span style={{ color: 'var(--ink)', fontWeight: 500 }}>{config.coverage}</span></span>
|
|
316
|
+
<span>·</span>
|
|
317
|
+
<span>{config.cadence}</span>
|
|
318
|
+
</div>
|
|
319
|
+
|
|
320
|
+
{/* search + mode switch */}
|
|
321
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginTop: 22, flexWrap: 'wrap' }}>
|
|
322
|
+
<label style={{
|
|
323
|
+
display: 'flex', alignItems: 'center', gap: 9,
|
|
324
|
+
flex: 1, minWidth: 240,
|
|
325
|
+
fontSize: 14, color: 'var(--ink)',
|
|
326
|
+
border: '0.5px solid var(--hairline-2)', borderRadius: 4,
|
|
327
|
+
padding: '10px 13px', background: 'var(--surface)',
|
|
328
|
+
}}>
|
|
329
|
+
<svg viewBox="0 0 24 24" width={15} height={15} fill="none" stroke="var(--tertiary)" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true">
|
|
330
|
+
<circle cx="11" cy="11" r="7" /><path d="M21 21l-4-4" />
|
|
331
|
+
</svg>
|
|
332
|
+
<input
|
|
333
|
+
value={query}
|
|
334
|
+
onChange={handleQuery}
|
|
335
|
+
placeholder={config.filterPlaceholder}
|
|
336
|
+
style={{
|
|
337
|
+
border: 'none', outline: 'none', background: 'transparent',
|
|
338
|
+
fontFamily: 'inherit', fontSize: 14, color: 'var(--ink)',
|
|
339
|
+
flex: 1, minWidth: 0,
|
|
340
|
+
}}
|
|
341
|
+
/>
|
|
342
|
+
</label>
|
|
343
|
+
|
|
344
|
+
<div style={{ display: 'flex', gap: 6, flex: 'none' }}>
|
|
345
|
+
{modeDefs.map((m) => (
|
|
346
|
+
<button
|
|
347
|
+
key={m.id}
|
|
348
|
+
onClick={() => { setMode(m.id); if (m.id !== 'search') setQuery(''); }}
|
|
349
|
+
style={{
|
|
350
|
+
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
|
|
351
|
+
padding: '9px 14px', borderRadius: 4,
|
|
352
|
+
border: `0.5px solid ${mode === m.id ? 'var(--ink)' : 'var(--hairline-2)'}`,
|
|
353
|
+
background: mode === m.id ? 'var(--ink)' : 'var(--surface)',
|
|
354
|
+
color: mode === m.id ? '#fff' : 'var(--secondary)',
|
|
355
|
+
cursor: 'pointer',
|
|
356
|
+
}}
|
|
357
|
+
>
|
|
358
|
+
{m.label}
|
|
359
|
+
</button>
|
|
360
|
+
))}
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
</section>
|
|
364
|
+
|
|
365
|
+
{/* ── LEAF / OVERVIEW ──────────────────────────────────────────── */}
|
|
366
|
+
{mode === 'leaf' && (
|
|
367
|
+
<>
|
|
368
|
+
{/* Popular cards */}
|
|
369
|
+
<section style={{ padding: '30px 0', borderBottom: '0.5px solid var(--hairline)' }}>
|
|
370
|
+
<h2 style={{ fontFamily: 'var(--mono)', fontSize: 20, fontWeight: 600, letterSpacing: '-0.01em', marginBottom: 16 }}>
|
|
371
|
+
Popular {config.noun}
|
|
372
|
+
</h2>
|
|
373
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 14 }}>
|
|
374
|
+
{popular.map((p) => (
|
|
375
|
+
<Link
|
|
376
|
+
key={p.sym}
|
|
377
|
+
href={`/${group}/${p.slug}`}
|
|
378
|
+
style={{
|
|
379
|
+
display: 'block',
|
|
380
|
+
background: 'var(--surface)',
|
|
381
|
+
border: '0.5px solid var(--hairline-2)',
|
|
382
|
+
borderRadius: 8,
|
|
383
|
+
padding: '18px 20px',
|
|
384
|
+
color: 'inherit',
|
|
385
|
+
textDecoration: 'none',
|
|
386
|
+
}}
|
|
387
|
+
>
|
|
388
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
389
|
+
<span style={{ fontFamily: 'var(--mono)', fontSize: 16, fontWeight: 600, letterSpacing: '-0.01em' }}>
|
|
390
|
+
{p.sym}
|
|
391
|
+
</span>
|
|
392
|
+
<StatusMark color={statusColor(p.status)} label={p.status} size={6} fontSize={12} />
|
|
393
|
+
</div>
|
|
394
|
+
<div style={{ fontSize: 13, color: 'var(--tertiary)', marginTop: 5 }}>{p.name}</div>
|
|
395
|
+
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginTop: 12 }}>
|
|
396
|
+
<span style={{ fontSize: 24, fontWeight: 600, letterSpacing: '-0.02em' }}>{p.last}</span>
|
|
397
|
+
<span style={{ fontSize: 13, fontWeight: 500, color: deltaColor(p.up) }}>{p.delta}</span>
|
|
398
|
+
</div>
|
|
399
|
+
</Link>
|
|
400
|
+
))}
|
|
401
|
+
</div>
|
|
402
|
+
</section>
|
|
403
|
+
|
|
404
|
+
{/* Browse by category */}
|
|
405
|
+
<section style={{ padding: '30px 0 8px' }}>
|
|
406
|
+
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap', marginBottom: 16 }}>
|
|
407
|
+
<h2 style={{ fontFamily: 'var(--mono)', fontSize: 20, fontWeight: 600, letterSpacing: '-0.01em' }}>
|
|
408
|
+
Browse by group
|
|
409
|
+
</h2>
|
|
410
|
+
<button
|
|
411
|
+
onClick={() => setMode('list')}
|
|
412
|
+
style={{
|
|
413
|
+
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
414
|
+
fontSize: 14, fontWeight: 500, cursor: 'pointer',
|
|
415
|
+
background: 'none', border: 'none', color: 'var(--ink)', fontFamily: 'inherit', padding: 0,
|
|
416
|
+
}}
|
|
417
|
+
>
|
|
418
|
+
View all {config.totalCount} {config.noun}
|
|
419
|
+
<svg viewBox="0 0 24 24" width={14} height={14} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
420
|
+
<path d="M5 12h14" /><path d="M13 6l6 6-6 6" />
|
|
421
|
+
</svg>
|
|
422
|
+
</button>
|
|
423
|
+
</div>
|
|
424
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 14 }}>
|
|
425
|
+
{categories.map((c) => (
|
|
426
|
+
<button
|
|
427
|
+
key={c.name}
|
|
428
|
+
onClick={() => setMode('list')}
|
|
429
|
+
style={{
|
|
430
|
+
display: 'block', textAlign: 'left',
|
|
431
|
+
background: 'var(--surface)',
|
|
432
|
+
border: '0.5px solid var(--hairline-2)',
|
|
433
|
+
borderRadius: 8,
|
|
434
|
+
padding: '18px 20px',
|
|
435
|
+
color: 'inherit',
|
|
436
|
+
cursor: 'pointer',
|
|
437
|
+
fontFamily: 'inherit',
|
|
438
|
+
width: '100%',
|
|
439
|
+
}}
|
|
440
|
+
>
|
|
441
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
442
|
+
<span style={{ fontSize: 15, fontWeight: 600, letterSpacing: '-0.01em' }}>{c.name}</span>
|
|
443
|
+
<span style={{ fontSize: 13, color: 'var(--tertiary)' }}>{c.count}</span>
|
|
444
|
+
</div>
|
|
445
|
+
<div style={{ fontSize: 13, color: 'var(--secondary)', marginTop: 7, lineHeight: 1.5 }}>{c.desc}</div>
|
|
446
|
+
</button>
|
|
447
|
+
))}
|
|
448
|
+
</div>
|
|
449
|
+
</section>
|
|
450
|
+
</>
|
|
451
|
+
)}
|
|
452
|
+
|
|
453
|
+
{/* ── LIST ────────────────────────────────────────────────────── */}
|
|
454
|
+
{mode === 'list' && (
|
|
455
|
+
<section style={{ padding: '26px 0 8px' }}>
|
|
456
|
+
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap', marginBottom: 14 }}>
|
|
457
|
+
<h2 style={{ fontFamily: 'var(--mono)', fontSize: 20, fontWeight: 600, letterSpacing: '-0.01em' }}>
|
|
458
|
+
{config.allSeriesLabel}
|
|
459
|
+
</h2>
|
|
460
|
+
<span style={{ fontSize: 13, color: 'var(--tertiary)' }}>{resultLabel}</span>
|
|
461
|
+
</div>
|
|
462
|
+
<RowTable rows={allRows} group={group} />
|
|
463
|
+
</section>
|
|
464
|
+
)}
|
|
465
|
+
|
|
466
|
+
{/* ── SEARCH ──────────────────────────────────────────────────── */}
|
|
467
|
+
{mode === 'search' && (
|
|
468
|
+
<section style={{ padding: '26px 0 8px' }}>
|
|
469
|
+
{/* Facets strip */}
|
|
470
|
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
|
471
|
+
{(['fx', 'metals', 'power'] as const).map((g) => (
|
|
472
|
+
<span
|
|
473
|
+
key={g}
|
|
474
|
+
style={{
|
|
475
|
+
fontSize: 12,
|
|
476
|
+
fontWeight: 500,
|
|
477
|
+
padding: '4px 11px',
|
|
478
|
+
borderRadius: 4,
|
|
479
|
+
border: '0.5px solid var(--hairline-2)',
|
|
480
|
+
color: 'var(--secondary)',
|
|
481
|
+
background: 'var(--surface)',
|
|
482
|
+
}}
|
|
483
|
+
>
|
|
484
|
+
{g}
|
|
485
|
+
</span>
|
|
486
|
+
))}
|
|
487
|
+
<span
|
|
488
|
+
style={{
|
|
489
|
+
fontSize: 12,
|
|
490
|
+
fontWeight: 500,
|
|
491
|
+
padding: '4px 11px',
|
|
492
|
+
borderRadius: 4,
|
|
493
|
+
border: '0.5px solid var(--hairline-2)',
|
|
494
|
+
color: 'var(--secondary)',
|
|
495
|
+
background: 'var(--surface)',
|
|
496
|
+
}}
|
|
497
|
+
>
|
|
498
|
+
All groups
|
|
499
|
+
</span>
|
|
500
|
+
</div>
|
|
501
|
+
<div style={{ fontSize: 13, color: 'var(--tertiary)', marginBottom: 14 }}>
|
|
502
|
+
{resultLabel} for "<span style={{ color: 'var(--ink)', fontWeight: 500 }}>{query}</span>"
|
|
503
|
+
</div>
|
|
504
|
+
{filtered.length > 0 ? (
|
|
505
|
+
<RowTable rows={filtered} group={group} />
|
|
506
|
+
) : (
|
|
507
|
+
<div
|
|
508
|
+
style={{
|
|
509
|
+
background: 'var(--surface)',
|
|
510
|
+
border: '0.5px solid var(--hairline-2)',
|
|
511
|
+
borderRadius: 8,
|
|
512
|
+
padding: '30px 18px',
|
|
513
|
+
textAlign: 'center',
|
|
514
|
+
fontSize: 14,
|
|
515
|
+
color: 'var(--tertiary)',
|
|
516
|
+
}}
|
|
517
|
+
>
|
|
518
|
+
No {config.noun} match "{query}" in {group}.{' '}
|
|
519
|
+
<span style={{ color: 'var(--secondary)' }}>{config.emptyHint}</span>
|
|
520
|
+
</div>
|
|
521
|
+
)}
|
|
522
|
+
</section>
|
|
523
|
+
)}
|
|
524
|
+
|
|
525
|
+
</div>
|
|
526
|
+
</main>
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|