Skip to content

Commit 0ef6ebc

Browse files
committed
feat: present Raindex market data page
1 parent 7dae402 commit 0ef6ebc

18 files changed

Lines changed: 1012 additions & 4 deletions

packages/webapp/.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
PUBLIC_WALLETCONNECT_PROJECT_ID=
1+
PUBLIC_WALLETCONNECT_PROJECT_ID=
2+
PUBLIC_MARKET_DATA_API_URL=http://127.0.0.1:8000

packages/webapp/ARCHITECTURE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ nix develop -c npm run dev
7777
and bulk withdraw.
7878
- Detail route: `/vaults/[chainId]-[raindex]-[id]` (components handle the
7979
heavy lifting inside `ui-components`).
80+
- `/markets`
81+
- Public Raindex market page for direct quote-token markets discovered from
82+
active Raindex orders. It renders the same SDK snapshot model served by the
83+
market-data REST API, including 24-hour statistics and executable depth.
8084
- `/deploy`
8185
- Loads a dotrain registry (`?registry=` query param or default
8286
`REGISTRY_URL`), validates orders, and shows valid/invalid sections.
@@ -130,6 +134,7 @@ Run inside a Nix shell for tool parity (`nix develop -c <cmd>`):
130134

131135
```bash
132136
PUBLIC_WALLETCONNECT_PROJECT_ID=<your_walletconnect_project_id>
137+
PUBLIC_MARKET_DATA_API_URL=https://api.raindex.finance
133138
```
134139

135140
- Notes

packages/webapp/src/__tests__/Sidebar.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@ vi.mock('svelte/store', async (importOriginal) => {
4848
});
4949

5050
const mockWindowSize = (width: number) => {
51-
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: width });
51+
Object.defineProperty(window, 'innerWidth', {
52+
writable: true,
53+
configurable: true,
54+
value: width
55+
});
5256
window.dispatchEvent(new Event('resize'));
5357
};
5458

@@ -72,6 +76,22 @@ describe('Sidebar', () => {
7276

7377
expect(container).toBeTruthy();
7478
});
79+
it('shows Markets immediately after Vaults', () => {
80+
mockWindowSize(1025);
81+
const mockColorTheme = writable('light');
82+
const mockPage = {
83+
url: { pathname: '/markets' }
84+
};
85+
render(Sidebar, { colorTheme: mockColorTheme, page: mockPage });
86+
87+
const vaults = screen.getByTestId('sidebar-vaults').closest('a');
88+
const markets = screen.getByTestId('sidebar-markets').closest('a');
89+
expect(vaults).not.toBeNull();
90+
expect(markets).not.toBeNull();
91+
expect(markets).toHaveAttribute('href', '/markets');
92+
expect(vaults!.compareDocumentPosition(markets!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
93+
expect(vaults!.parentElement?.nextElementSibling).toContainElement(markets);
94+
});
7595
it('renders menu bars button when screen width is small', () => {
7696
// Mock small screen width
7797
mockWindowSize(500);
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
<script lang="ts">
2+
import { createQuery } from '@tanstack/svelte-query';
3+
import { derived, writable } from 'svelte/store';
4+
import { getMarket } from '$lib/market-api/client';
5+
import { formatPrice, formatSpread, formatVolume, shortAddress } from '$lib/market-api/format';
6+
import TokenPairLogos from './TokenPairLogos.svelte';
7+
8+
export let tickerId: string | undefined;
9+
10+
const selectedTickerId = writable<string | undefined>();
11+
$: $selectedTickerId = tickerId;
12+
const detailQuery = createQuery(
13+
derived(selectedTickerId, ($tickerId) => ({
14+
queryKey: ['market-api', 'market', $tickerId],
15+
queryFn: ({ signal }: { signal: AbortSignal }) =>
16+
getMarket($tickerId as string, undefined, undefined, signal),
17+
enabled: Boolean($tickerId),
18+
staleTime: 60_000,
19+
refetchInterval: 60_000,
20+
retry: 1
21+
}))
22+
);
23+
24+
$: snapshot = $detailQuery.data;
25+
</script>
26+
27+
<aside
28+
class="min-h-[28rem] rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900"
29+
aria-label="Selected market details"
30+
>
31+
{#if !tickerId}
32+
<div class="flex min-h-[28rem] flex-col items-center justify-center px-8 text-center">
33+
<div class="mb-4 flex size-12 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 font-mono text-lg text-gray-400 dark:border-gray-700 dark:bg-gray-800">↕</div>
34+
<h2 class="font-semibold text-gray-900 dark:text-white">Inspect executable depth</h2>
35+
<p class="mt-2 max-w-xs text-sm leading-6 text-gray-500 dark:text-gray-400">
36+
Select a market to quote its live Raindex orders. Overview statistics remain cached and available independently.
37+
</p>
38+
</div>
39+
{:else if $detailQuery.isPending}
40+
<div class="space-y-4 p-5" aria-label="Loading market depth">
41+
<div class="h-6 w-40 animate-pulse rounded bg-gray-200 dark:bg-gray-800"></div>
42+
<div class="grid grid-cols-3 gap-2">
43+
{#each [0, 1, 2] as skeleton (skeleton)}
44+
<div class="h-20 animate-pulse rounded-md bg-gray-100 dark:bg-gray-800"></div>
45+
{/each}
46+
</div>
47+
<div class="h-56 animate-pulse rounded-md bg-gray-100 dark:bg-gray-800"></div>
48+
</div>
49+
{:else if $detailQuery.isError && !snapshot}
50+
<div class="flex min-h-[28rem] flex-col items-center justify-center px-8 text-center" role="alert">
51+
<h2 class="font-semibold text-gray-900 dark:text-white">Depth is temporarily unavailable</h2>
52+
<p class="mt-2 max-w-xs text-sm leading-6 text-gray-500 dark:text-gray-400">
53+
The service could not complete this market’s executable quote. Cached market statistics are unaffected.
54+
</p>
55+
<button
56+
type="button"
57+
class="mt-4 rounded-md bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
58+
on:click={() => $detailQuery.refetch()}
59+
>
60+
Retry quote
61+
</button>
62+
</div>
63+
{:else if snapshot}
64+
<div class="border-b border-gray-200 p-5 dark:border-gray-700">
65+
<div class="flex flex-col items-start gap-3 sm:flex-row sm:justify-between sm:gap-4">
66+
<div class="flex min-w-0 items-center gap-3">
67+
<TokenPairLogos base={snapshot.market.base} quote={snapshot.market.quote} large />
68+
<div class="min-w-0">
69+
<p class="text-xs font-medium uppercase tracking-wide text-gray-500">Executable market</p>
70+
<h2 class="mt-1 truncate text-xl font-semibold text-gray-900 dark:text-white">
71+
{snapshot.market.base.symbol} / {snapshot.market.quote.symbol}
72+
</h2>
73+
{#if $detailQuery.isRefetchError}
74+
<p
75+
class="mt-1.5 inline-flex items-center gap-1.5 text-xs font-medium text-amber-700 dark:text-amber-300"
76+
role="status"
77+
title="Showing the last successful quote because the latest refresh failed."
78+
>
79+
<span class="size-1.5 rounded-full bg-amber-500" aria-hidden="true"></span>
80+
Cached quote · refresh failed
81+
</p>
82+
{/if}
83+
</div>
84+
</div>
85+
<span class="rounded border border-gray-200 px-2 py-1 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400">Chain {snapshot.market.chainId}</span>
86+
</div>
87+
<div class="mt-5 grid grid-cols-3 overflow-hidden rounded-md border border-gray-200 dark:border-gray-700">
88+
<div class="p-3">
89+
<div class="text-xs font-medium text-emerald-600 dark:text-emerald-400">Best bid</div>
90+
<div class="mt-1 truncate font-mono text-sm tabular-nums text-gray-900 dark:text-white" title={snapshot.orderbook.bestBid}>
91+
{formatPrice(snapshot.orderbook.bestBid)}
92+
</div>
93+
</div>
94+
<div class="border-x border-gray-200 p-3 text-center dark:border-gray-700">
95+
<div class="text-xs font-medium text-gray-500">Spread</div>
96+
<div class="mt-1 font-mono text-sm tabular-nums text-gray-900 dark:text-white">
97+
{formatSpread(snapshot.orderbook.bestBid, snapshot.orderbook.bestAsk)}
98+
</div>
99+
</div>
100+
<div class="p-3 text-right">
101+
<div class="text-xs font-medium text-rose-600 dark:text-rose-400">Best ask</div>
102+
<div class="mt-1 truncate font-mono text-sm tabular-nums text-gray-900 dark:text-white" title={snapshot.orderbook.bestAsk}>
103+
{formatPrice(snapshot.orderbook.bestAsk)}
104+
</div>
105+
</div>
106+
</div>
107+
</div>
108+
109+
<div class="grid grid-cols-2 gap-px border-b border-gray-200 bg-gray-200 dark:border-gray-700 dark:bg-gray-700">
110+
<div class="bg-white p-4 dark:bg-gray-900">
111+
<div class="text-xs text-gray-500">Last price</div>
112+
<div class="mt-1 truncate font-mono text-sm tabular-nums text-gray-900 dark:text-white" title={snapshot.stats.lastPrice}>{formatPrice(snapshot.stats.lastPrice)}</div>
113+
</div>
114+
<div class="bg-white p-4 dark:bg-gray-900">
115+
<div class="text-xs text-gray-500">24h trades</div>
116+
<div class="mt-1 font-mono text-sm tabular-nums text-gray-900 dark:text-white">{snapshot.stats.tradeCount24h.toLocaleString()}</div>
117+
</div>
118+
<div class="bg-white p-4 dark:bg-gray-900">
119+
<div class="text-xs text-gray-500">Base volume</div>
120+
<div class="mt-1 truncate font-mono text-sm tabular-nums text-gray-900 dark:text-white" title={snapshot.stats.baseVolume24h}>{formatVolume(snapshot.stats.baseVolume24h)} {snapshot.market.base.symbol}</div>
121+
</div>
122+
<div class="bg-white p-4 dark:bg-gray-900">
123+
<div class="text-xs text-gray-500">Quote volume</div>
124+
<div class="mt-1 truncate font-mono text-sm tabular-nums text-gray-900 dark:text-white" title={snapshot.stats.targetVolume24h}>{formatVolume(snapshot.stats.targetVolume24h)} {snapshot.market.quote.symbol}</div>
125+
</div>
126+
<div class="col-span-2 bg-white p-4 dark:bg-gray-900">
127+
<div class="text-xs text-gray-500">24h range</div>
128+
<div class="mt-1 truncate font-mono text-sm tabular-nums text-gray-900 dark:text-white" title={`${snapshot.stats.low24h ?? ''} — ${snapshot.stats.high24h ?? ''} ${snapshot.market.quote.symbol}`}>
129+
<span title={snapshot.stats.low24h}>{formatPrice(snapshot.stats.low24h)}</span>
130+
<span class="mx-2 text-gray-400">—</span>
131+
<span title={snapshot.stats.high24h}>{formatPrice(snapshot.stats.high24h)}</span>
132+
{snapshot.market.quote.symbol}
133+
</div>
134+
</div>
135+
</div>
136+
137+
<div class="p-5">
138+
<div class="mb-3 flex items-center justify-between">
139+
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">Orderbook</h3>
140+
<span class="text-xs text-gray-500">Top 10 per side</span>
141+
</div>
142+
<div class="grid grid-cols-2 gap-4">
143+
{#each [{ label: 'Bids', levels: snapshot.orderbook.bids, color: 'text-emerald-600 dark:text-emerald-400' }, { label: 'Asks', levels: snapshot.orderbook.asks, color: 'text-rose-600 dark:text-rose-400' }] as side}
144+
<div>
145+
<div class="mb-2 grid grid-cols-2 text-xs text-gray-500">
146+
<span class={side.color}>{side.label}</span><span class="text-right">Size</span>
147+
</div>
148+
<div class="space-y-1.5">
149+
{#each side.levels.slice(0, 10) as level}
150+
<div class="grid grid-cols-2 gap-2 font-mono text-xs tabular-nums" title={`Price ${level.price}, size ${level.baseQuantity}`}>
151+
<span class="truncate text-gray-900 dark:text-gray-100">{formatPrice(level.price)}</span>
152+
<span class="truncate text-right text-gray-500">{formatVolume(level.baseQuantity)}</span>
153+
</div>
154+
{/each}
155+
{#if side.levels.length === 0}<p class="text-xs text-gray-400">No executable levels</p>{/if}
156+
</div>
157+
</div>
158+
{/each}
159+
</div>
160+
</div>
161+
162+
<div class="border-t border-gray-200 p-5 dark:border-gray-700">
163+
<div class="mb-3 flex items-center justify-between">
164+
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">Recent trades</h3>
165+
<span class="text-xs text-gray-500">Latest 8</span>
166+
</div>
167+
{#if snapshot.recentTrades.length > 0}
168+
<div class="space-y-2">
169+
<div class="grid grid-cols-[3rem_1fr_1fr] gap-2 text-xs text-gray-500">
170+
<span>Side</span><span>Price</span><span class="text-right">Size</span>
171+
</div>
172+
{#each snapshot.recentTrades.slice(0, 8) as trade (trade.tradeId)}
173+
<div class="grid grid-cols-[3rem_1fr_1fr] gap-2 font-mono text-xs tabular-nums" title={new Date(trade.timestamp * 1000).toLocaleString()}>
174+
<span class={`uppercase ${trade.side === 'buy' ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'}`}>{trade.side}</span>
175+
<span class="truncate text-gray-900 dark:text-gray-100" title={trade.price}>{formatPrice(trade.price)}</span>
176+
<span class="truncate text-right text-gray-500" title={trade.baseVolume}>{formatVolume(trade.baseVolume)}</span>
177+
</div>
178+
{/each}
179+
</div>
180+
{:else}
181+
<p class="text-xs text-gray-400">No trades in the current 24-hour window.</p>
182+
{/if}
183+
</div>
184+
185+
{#if snapshot.errors.length > 0}
186+
<div class="border-t border-amber-200 bg-amber-50 px-5 py-4 text-xs text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/20 dark:text-amber-200">
187+
{#each snapshot.errors as issue}
188+
<p><span class="font-medium capitalize">{issue.source} {issue.severity}:</span> {issue.message}</p>
189+
{/each}
190+
</div>
191+
{/if}
192+
193+
<details class="border-t border-gray-200 p-5 text-xs dark:border-gray-700">
194+
<summary class="cursor-pointer font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:text-gray-300">Exact API values</summary>
195+
<p class="mt-2 leading-5 text-gray-500 dark:text-gray-400">
196+
Unrounded values returned by the Raindex market API.
197+
</p>
198+
<dl class="mt-3 grid gap-3 text-gray-500 dark:text-gray-400">
199+
{#each [
200+
['Last price', snapshot.stats.lastPrice ?? ''],
201+
['24h low', snapshot.stats.low24h ?? ''],
202+
['24h high', snapshot.stats.high24h ?? ''],
203+
['Base volume', snapshot.stats.baseVolume24h],
204+
['Quote volume', snapshot.stats.targetVolume24h],
205+
['Best bid', snapshot.orderbook.bestBid ?? ''],
206+
['Best ask', snapshot.orderbook.bestAsk ?? '']
207+
] as value}
208+
<div class="min-w-0">
209+
<dt class="font-medium text-gray-700 dark:text-gray-300">{value[0]}</dt>
210+
<dd class="mt-0.5 break-all font-mono tabular-nums">{value[1]}</dd>
211+
</div>
212+
{/each}
213+
</dl>
214+
</details>
215+
216+
<details class="border-t border-gray-200 p-5 text-xs dark:border-gray-700">
217+
<summary class="cursor-pointer font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:text-gray-300">Market provenance</summary>
218+
<dl class="mt-3 space-y-2 text-gray-500 dark:text-gray-400">
219+
<div><dt class="inline font-medium">Ticker:</dt> <dd class="inline font-mono" title={snapshot.market.tickerId}>{shortAddress(snapshot.market.tickerId)}</dd></div>
220+
<div><dt class="inline font-medium">Base:</dt> <dd class="inline font-mono" title={snapshot.market.base.address}>{shortAddress(snapshot.market.base.address)}</dd></div>
221+
<div><dt class="inline font-medium">Quote:</dt> <dd class="inline font-mono" title={snapshot.market.quote.address}>{shortAddress(snapshot.market.quote.address)}</dd></div>
222+
<div><dt class="inline font-medium">Observed:</dt> <dd class="inline">{new Date(snapshot.observedAt * 1000).toLocaleString()}</dd></div>
223+
</dl>
224+
</details>
225+
{/if}
226+
</aside>
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { render, screen } from '@testing-library/svelte';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import type { RaindexMarketSnapshot } from '@rainlanguage/raindex';
4+
import MarketDetailPanel from './MarketDetailPanel.svelte';
5+
6+
const queryState = vi.hoisted(() => ({ value: {} as Record<string, unknown> }));
7+
8+
vi.mock('$env/dynamic/public', () => ({ env: {} }));
9+
10+
vi.mock('@tanstack/svelte-query', () => ({
11+
createQuery: () => ({
12+
subscribe(run: (value: Record<string, unknown>) => void) {
13+
run(queryState.value);
14+
return () => undefined;
15+
}
16+
})
17+
}));
18+
19+
const snapshot: RaindexMarketSnapshot = {
20+
market: {
21+
id: '8453:base:quote',
22+
tickerId: '0xbase_0xquote',
23+
chainId: 8453,
24+
base: {
25+
chainId: 8453,
26+
address: '0xbase',
27+
name: 'Base',
28+
symbol: 'BASE',
29+
decimals: 18,
30+
logoUri: 'https://assets.example/base.png',
31+
variants: []
32+
},
33+
quote: {
34+
chainId: 8453,
35+
address: '0xquote',
36+
name: 'Quote',
37+
symbol: 'QUOTE',
38+
decimals: 6,
39+
logoUri: 'https://assets.example/quote.png',
40+
variants: []
41+
},
42+
raindexAddresses: ['0xraindex']
43+
},
44+
orderbook: {
45+
bestBid: '99',
46+
bestAsk: '101',
47+
bids: [
48+
{
49+
price: '99',
50+
baseQuantity: '1',
51+
targetQuantity: '99',
52+
chainId: 8453,
53+
raindex: '0xraindex',
54+
orderHash: '0xshared',
55+
sourceToken: '0xinput-a',
56+
blockNumber: 1
57+
},
58+
{
59+
price: '98',
60+
baseQuantity: '2',
61+
targetQuantity: '196',
62+
chainId: 8453,
63+
raindex: '0xraindex',
64+
orderHash: '0xshared',
65+
sourceToken: '0xinput-b',
66+
blockNumber: 1
67+
}
68+
],
69+
asks: []
70+
},
71+
stats: {
72+
lastPrice: '100.123456789',
73+
high24h: '102',
74+
low24h: '97',
75+
baseVolume24h: '12.5',
76+
targetVolume24h: '1250',
77+
tradeCount24h: 3
78+
},
79+
recentTrades: [],
80+
observedAt: 100,
81+
errors: []
82+
};
83+
84+
describe('MarketDetailPanel', () => {
85+
it('keeps cached detail visible after refresh failure and renders repeated order hashes', () => {
86+
queryState.value = {
87+
data: snapshot,
88+
isPending: false,
89+
isError: true,
90+
isRefetchError: true,
91+
refetch: vi.fn()
92+
};
93+
94+
render(MarketDetailPanel, {
95+
props: { tickerId: snapshot.market.tickerId }
96+
});
97+
98+
expect(screen.getByText('Cached quote · refresh failed')).toBeInTheDocument();
99+
expect(screen.getByRole('heading', { name: 'Orderbook' })).toBeInTheDocument();
100+
expect(
101+
document.querySelector('img[src="https://assets.example/base.png"]')
102+
).toBeInTheDocument();
103+
expect(
104+
document.querySelector('img[src="https://assets.example/quote.png"]')
105+
).toBeInTheDocument();
106+
expect(screen.getByText('100.12346')).toBeInTheDocument();
107+
expect(screen.getByText('Exact API values')).toBeInTheDocument();
108+
expect(screen.getByText('100.123456789')).toBeInTheDocument();
109+
expect(screen.getByTitle('Price 99, size 1')).toBeInTheDocument();
110+
expect(screen.getByTitle('Price 98, size 2')).toBeInTheDocument();
111+
});
112+
});

0 commit comments

Comments
 (0)